fix(browser): report attached locale coverage limits

Fixes #3395.

Disclose attached locale coverage in complete and retained partial captures,
clarify the shared locale contract, and verify actual CLI/MCP caller-language
preservation and browser cleanup. Launch supplies full page/request locale.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 19:37:19 +08:00
1 parent c9f9b83c6f
commit c7697595d8
6 files changed
+118 -12

No files matched your search

+9
View File
@@ -144,6 +144,15 @@ distinction: it closes and deletes provider-owned profiles, but only disconnects
from an external CDP browser. Real-browser verification checks both outcomes and
confirms that an attached external browser remains alive.
`environment.locale` sets Intl formatting in both modes. Launch also sets
`navigator.language`, `navigator.languages`, and the request `Accept-Language`.
Connect preserves the caller's page and request languages; every attached
capture identifies this locale limitation, including retained partial
observations. Use launch mode when the application must select the requested
language through navigator values or request headers. Artifact completeness
covers the selected observations, not equivalence of launch and connect
language environments.
HTTP(S) URLs may include ordinary query values and fragments directly. Use
ordered structured query entries when a value needs to reference a declared
environment-backed secret. Form, storage, and cookie values are either literal
+14 -1
View File
@@ -12,13 +12,22 @@ export async function startBrowserVerifierSite() {
return;
}
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
if (requestUrl.pathname === "/scenario-request-language") {
response.setHeader("content-type", "application/json");
response.setHeader("cache-control", "no-store");
response.end(JSON.stringify(request.headers["accept-language"] ?? null));
return;
}
if (requestUrl.pathname === "/scenario-environment") {
const requestedRun = requestUrl.searchParams.get("run");
const run = ["A", "B", "C"].includes(requestedRun)
? requestedRun
: "default";
response.setHeader("content-type", "text/html");
response.end(`<!doctype html><html><body data-run="${run}"><pre id="environment"></pre>
const requestLanguage = encodeURIComponent(
JSON.stringify(request.headers["accept-language"] ?? null),
);
response.end(`<!doctype html><html><body data-run="${run}" data-request-language="${requestLanguage}"><pre id="environment"></pre><pre id="language"></pre>
<button id="refresh-environment">Refresh environment</button><script>
const refresh = () => document.querySelector('#environment').textContent = JSON.stringify({
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -26,6 +35,10 @@ export async function startBrowserVerifierSite() {
dpr: devicePixelRatio, width: innerWidth, height: innerHeight
});
document.querySelector('#refresh-environment').onclick = refresh;
document.querySelector('#language').textContent = JSON.stringify({
language: navigator.language, languages: [...navigator.languages],
request_language: JSON.parse(decodeURIComponent(document.body.dataset.requestLanguage))
});
window.releaseScenario = () => {
const release = document.createElement('span');
release.id = 'release-' + document.body.dataset.run;
@@ -97,8 +97,32 @@ const assertCapture = (capture, values = expected) => {
}
};
const assertCallerLanguage = (capture, baseline) => {
assert.ok(
capture.limitations.some((value) =>
value.includes("Attached locale fr-FR applies to Intl formatting only"),
),
"Attached capture must identify its page/request language limitation",
);
for (const step of capture.steps) {
const text = step.artifacts.dom.value.text;
const observed = /<pre id="language">([^<]+)<\/pre>/u.exec(text);
assert.ok(
observed,
"DOM omitted the navigator/request language observations",
);
assert.deepEqual(JSON.parse(observed[1]), baseline);
}
};
/** Verify actual attached-page emulation and restoration across scenario outcomes. */
export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
const languageExpression = `(async () => JSON.stringify({
language: navigator.language, languages: [...navigator.languages],
request_language: await (await fetch(
${JSON.stringify(`${origin}/scenario-request-language`)}, { cache: "no-store" }
)).json()
}))()`;
const targets = await (await fetch(`${endpoint}/json/list`)).json();
const target = targets.find(({ id }) => id === targetId);
assert.ok(target, "External fixture target is missing");
@@ -106,10 +130,11 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
target.webSocketDebuggerUrl,
"capture_browser_scenario",
);
const readEnvironment = async () => {
const readEnvironment = async (expressionToRead = expression) => {
const value = await connection.send("Runtime.evaluate", {
expression,
expression: expressionToRead,
returnByValue: true,
awaitPromise: true,
});
assert.equal(value.result.type, "string");
return JSON.parse(value.result.value);
@@ -118,6 +143,7 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
let verificationError;
try {
const baseline = await readEnvironment();
const callerLanguage = await readEnvironment(languageExpression);
const scenario = environmentScenario({
endpoint,
targetId,
@@ -126,10 +152,16 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
});
const assertRestored = async () => {
assert.deepEqual(await readEnvironment(), baseline);
assert.deepEqual(
await readEnvironment(languageExpression),
callerLanguage,
);
const remaining = await (await fetch(`${endpoint}/json/list`)).json();
assert.ok(remaining.some(({ id }) => id === targetId));
};
assertCapture((await runScenarioCli(scenario)).normalized_result);
const cliCapture = (await runScenarioCli(scenario)).normalized_result;
assertCapture(cliCapture);
assertCallerLanguage(cliCapture, callerLanguage);
await assertRestored();
const transport = new StdioClientTransport({
command: process.execPath,
@@ -147,15 +179,15 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
({ name }) => name === "capture_browser_scenario",
);
assert.ok(definition, "Scenario tool is absent from the current catalog");
assertCapture(
requireMcpEvidenceResult(
await client.callTool(
{ name: "capture_browser_scenario", arguments: scenario },
{ toolDefinition: definition },
),
"capture_browser_scenario",
const mcpCapture = requireMcpEvidenceResult(
await client.callTool(
{ name: "capture_browser_scenario", arguments: scenario },
{ toolDefinition: definition },
),
"capture_browser_scenario",
);
assertCapture(mcpCapture);
assertCallerLanguage(mcpCapture, callerLanguage);
await verifyConcurrentMcpCapture({
client,
definition,
@@ -201,6 +233,8 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
stdio_mcp: true,
observed: expected,
restored: baseline,
caller_language_preserved: callerLanguage,
attached_locale_limitation: true,
initialization_failure_cleanup: true,
action_failure_cleanup: true,
cancellation_cleanup: true,
@@ -56,6 +56,7 @@ const browserScenarioLimitations = (
...(session.mode === "connect"
? [
"CDP attachment cannot recover pre-attach events or guarantee launch-time context options.",
`Attached locale ${scenario.environment.locale} applies to Intl formatting only; navigator.language(s) and Accept-Language remain caller-controlled. Use launch mode for matching page and request languages.`,
]
: []),
];
@@ -730,3 +730,49 @@ it("keeps failed event finalization failed across cleanup retries", async () =>
expect(finishEvents).toHaveBeenCalledTimes(1);
expect(closeBrowser).toHaveBeenCalledTimes(1);
});
it.each(["launch", "connect"] as const)(
"reports locale coverage in %s success and retained operation/cleanup failures",
async (mode) => {
const input = scenario({ mode });
input.environment.locale = "fr-FR";
for (const failures of [
{},
{
capture: {
call: 1,
error: new AnalysisCancelledError("capture_browser_scenario"),
},
},
{ close: new Error("fixture cleanup interrupted") },
]) {
const session = new FakeSession(mode, failures);
const provider = new PlaywrightBrowserScenarioProvider(
async () => session,
);
const result = await provider.captureScenario(input);
expect(result.ok).toBe(!("capture" in failures || "close" in failures));
const expected = {
limitations:
mode === "connect"
? expect.arrayContaining([
"Attached locale fr-FR applies to Intl formatting only; navigator.language(s) and Accept-Language remain caller-controlled. Use launch mode for matching page and request languages.",
])
: expect.not.arrayContaining([
expect.stringContaining("Attached locale"),
]),
};
if (result.ok) expect(result.value).toMatchObject(expected);
else
expect(projectAnalysisError(result.error)).toMatchObject({
details: {
partial_observation: {
kind: "browser-scenario-observation",
capture: expected,
},
},
});
expect(session.closeCalls).toBe(1);
}
},
);
+4 -1
View File
@@ -107,7 +107,10 @@ export const browserScenarioEnvironmentSchema = z
.string()
.min(2)
.regex(/^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/u)
.default("en-US"),
.default("en-US")
.describe(
"Formatting locale. Launch also sets navigator.language(s) and Accept-Language; connect retains the caller's page and request languages and reports this limitation.",
),
timezone: z
.string()
.min(1)