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:
6 files changed
+118
-12
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user