diff --git a/src/browser/IsolatedWebBundleAnalyzer.test.ts b/src/browser/IsolatedWebBundleAnalyzer.test.ts index 131f839750..d4d5df8176 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.test.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.test.ts @@ -1,6 +1,6 @@ import { expect, it } from "vitest"; +import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js"; -import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; import { AnalysisCancelledError, AnalysisOutputError, @@ -13,7 +13,6 @@ import type { } from "../domain/webBundleAnalysis.js"; import { analyzeWebBundleInputSchema } from "../domain/webBundleAnalysis.js"; import { analyzeCapturedWebScript } from "../domain/webBundleAnalyzer.js"; -import { createWebTextArtifact } from "../domain/webContentArtifact.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok } from "../domain/result.js"; import { @@ -373,107 +372,12 @@ const deferred = (): { }; const inspection = (sources: readonly string[]) => - webPageInspectionSchema.parse({ - browser: { - product: "Fake Chrome", - protocol_version: "1.3", - revision: "1", - user_agent: "fake", - js_version: "1", - }, - target: { - target_id: "page-1", - type: "page", - title: "App", - url: `${origin}/app`, + createWebPageInspection( + sources.map((source, index) => ({ + source, + scriptKey: `scr_${String(index + 1).repeat(64)}`, + url: `${origin}/assets/${String(index)}.js`, origin, - attached: false, - }, - capture_window: { - started_at: "2026-07-14T00:00:00.000Z", - ended_at: "2026-07-14T00:00:01.000Z", - observation_ms: 1_000, - }, - completeness: { - status: "attach_limited", - conditions: ["attach_limited"], - policy_filtered_sections: [], - attach_limited_sections: ["network_requests"], - truncated_sections: [], - unavailable_sections: [], - excluded: [], - dropped_events: { - scripts: 0, - network_requests: 0, - console_events: 0, - websocket_connections: 0, - websocket_frames: 0, - webmcp_tools: 0, - timeline_events: 0, - total: 0, - }, - }, - frames: [], - dom: { total_nodes: 0, nodes: [] }, - accessibility: { - total_nodes: 0, - text_capture: { - status: "not_approved", - retained_bytes: 0, - excluded_fields: 0, - }, - nodes: [], - }, - scripts: { - total: sources.length, - items: sources.map((source, index) => ({ - script_key: `scr_${String(index + 1).repeat(64)}`, - url: `${origin}/assets/${String(index)}.js`, - origin, - cdp_hash: `hash-${String(index)}`, - length: Buffer.byteLength(source), - is_module: true, - language: "JavaScript", - source_map_url: null, - resource_reconciliation: { - status: "unmatched", - reason: "no_exact_sanitized_url", - }, - source: { - included: true, - artifact: createWebTextArtifact(source, "text/javascript"), - }, - })), - }, - resources: [], - network: { - requests: [], - websocket_connections: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - console: { - events: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - workers: [], - metadata: { - responses: [], - dom_urls: [], - agent_hints: [], - excluded_dom_urls: 0, - headers_allowlisted: true, - }, - storage: { - origin, - usage_bytes: null, - quota_bytes: null, - local_storage_keys: [], - session_storage_keys: [], - indexed_db_names: [], - cache_names: [], - values_redacted: true, - }, - limitations: [], - }); + cdpHash: `hash-${String(index)}`, + })), + ); diff --git a/src/browser/PlaywrightScenarioSession.test.ts b/src/browser/PlaywrightScenarioSession.test.ts index 04125b3ddb..7615c27dd6 100644 --- a/src/browser/PlaywrightScenarioSession.test.ts +++ b/src/browser/PlaywrightScenarioSession.test.ts @@ -216,6 +216,51 @@ const policyResponse = (method: string): unknown => ? { result: { value: null } } : {}; +const prepareLateStopRetry = async () => { + const { cleanup, page, storage } = fixture(); + const cancellation = new AbortController(); + storage?.close.mockResolvedValue({ loadingStopped: false }); + const attached = connectedScenario("block"); + const session = await PlaywrightScenarioSession.open( + attached, + {}, + { signal: cancellation.signal }, + ); + const { promise: held, resolve: finishRestoration } = deferred(); + const { promise: started, resolve: restorationStarted } = deferred(); + const { promise: transportClosed, resolve: signalTransportClosed } = + deferred(); + mocks.policyTransportClose.mockImplementation(async () => { + signalTransportClosed(); + }); + mocks.policySend.mockImplementation(async (method: string) => { + if (method === "Runtime.evaluate") { + restorationStarted(); + await held; + } + return policyResponse(method); + }); + + vi.useFakeTimers(); + try { + const firstClose = session.close(false).then( + () => ({ ok: true as const }), + (cause: unknown) => ({ ok: false as const, cause }), + ); + await started; + await vi.advanceTimersByTimeAsync(5_001); + await expect(firstClose).resolves.toMatchObject({ ok: false }); + finishRestoration(); + await transportClosed; + await vi.advanceTimersByTimeAsync(0); + cancellation.abort(); + return { cleanup, page, session }; + } catch (cause) { + vi.useRealTimers(); + throw cause; + } +}; + beforeEach(() => { vi.clearAllMocks(); mocks.policySend.mockReset(); @@ -661,44 +706,8 @@ it("retries a failed connected-page stop when service workers are allowed", asyn }); it("retains a late fallback stop failure for retry", async () => { - const { cleanup, page, storage } = fixture(); - const cancellation = new AbortController(); - storage?.close.mockResolvedValue({ loadingStopped: false }); - const attached = connectedScenario("block"); - const session = await PlaywrightScenarioSession.open( - attached, - {}, - { signal: cancellation.signal }, - ); - const { promise: held, resolve: finishRestoration } = deferred(); - const { promise: started, resolve: restorationStarted } = deferred(); - const { promise: transportClosed, resolve: signalTransportClosed } = - deferred(); - mocks.policyTransportClose.mockImplementation(async () => { - signalTransportClosed(); - }); - mocks.policySend.mockImplementation(async (method: string) => { - if (method === "Runtime.evaluate") { - restorationStarted(); - await held; - } - return policyResponse(method); - }); - - vi.useFakeTimers(); + const { cleanup, page, session } = await prepareLateStopRetry(); try { - const firstClose = session.close(false).then( - () => ({ ok: true as const }), - (cause: unknown) => ({ ok: false as const, cause }), - ); - await started; - await vi.advanceTimersByTimeAsync(5_001); - await expect(firstClose).resolves.toMatchObject({ ok: false }); - finishRestoration(); - await transportClosed; - await vi.advanceTimersByTimeAsync(0); - - cancellation.abort(); const stopFailure = new Error("late main-frame stop failed"); const detachedFrame = { evaluate: vi.fn(async () => { @@ -734,44 +743,8 @@ it("retains a late fallback stop failure for retry", async () => { }); it("coalesces concurrent late-stop close retries", async () => { - const { cleanup, page, storage } = fixture(); - const cancellation = new AbortController(); - storage?.close.mockResolvedValue({ loadingStopped: false }); - const attached = connectedScenario("block"); - const session = await PlaywrightScenarioSession.open( - attached, - {}, - { signal: cancellation.signal }, - ); - const { promise: held, resolve: finishRestoration } = deferred(); - const { promise: started, resolve: restorationStarted } = deferred(); - const { promise: transportClosed, resolve: signalTransportClosed } = - deferred(); - mocks.policyTransportClose.mockImplementation(async () => { - signalTransportClosed(); - }); - mocks.policySend.mockImplementation(async (method: string) => { - if (method === "Runtime.evaluate") { - restorationStarted(); - await held; - } - return policyResponse(method); - }); - - vi.useFakeTimers(); + const { cleanup, page, session } = await prepareLateStopRetry(); try { - const firstClose = session.close(false).then( - () => ({ ok: true as const }), - (cause: unknown) => ({ ok: false as const, cause }), - ); - await started; - await vi.advanceTimersByTimeAsync(5_001); - await expect(firstClose).resolves.toMatchObject({ ok: false }); - finishRestoration(); - await transportClosed; - await vi.advanceTimersByTimeAsync(0); - - cancellation.abort(); const { promise: heldStop, resolve: finishStop } = deferred(); const { promise: startedStop, resolve: stopStarted } = deferred(); page.evaluate.mockImplementation(async (script: unknown) => { diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index f7af053d30..c8b5f820fa 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; +import { createWebPageInspection } from "../../tests/support/browser/webPageInspection.js"; -import { webPageInspectionSchema } from "./browserObservationSchemas.js"; import { analyzeJavaScriptStaticSource } from "./javascript/javascriptStaticAnalysis.js"; import { analyzeCapturedWebBundle, @@ -1146,112 +1146,15 @@ describe("deep captured web bundle syntax", () => { }); const inspection = (source: string) => - webPageInspectionSchema.parse({ - browser: { - product: "Fake Chrome", - protocol_version: "1.3", - revision: "1", - user_agent: "fake", - js_version: "1", - }, - target: { - target_id: "page-1", - type: "page", - title: "App", - url: `${origin}/app`, + createWebPageInspection([ + { + source, + scriptKey: `scr_${"1".repeat(64)}`, + url: `${origin}/assets/app.js`, origin, - attached: false, + cdpHash: "hash", }, - capture_window: { - started_at: "2026-07-14T00:00:00.000Z", - ended_at: "2026-07-14T00:00:01.000Z", - observation_ms: 1_000, - }, - completeness: { - status: "attach_limited", - conditions: ["attach_limited"], - policy_filtered_sections: [], - attach_limited_sections: ["network_requests"], - truncated_sections: [], - unavailable_sections: [], - excluded: [], - dropped_events: { - scripts: 0, - network_requests: 0, - console_events: 0, - websocket_connections: 0, - websocket_frames: 0, - webmcp_tools: 0, - timeline_events: 0, - total: 0, - }, - }, - frames: [], - dom: { total_nodes: 0, nodes: [] }, - accessibility: { - total_nodes: 0, - text_capture: { - status: "not_approved", - retained_bytes: 0, - excluded_fields: 0, - }, - nodes: [], - }, - scripts: { - total: 1, - items: [ - { - script_key: `scr_${"1".repeat(64)}`, - url: `${origin}/assets/app.js`, - origin, - cdp_hash: "hash", - length: Buffer.byteLength(source), - is_module: true, - language: "JavaScript", - source_map_url: null, - resource_reconciliation: { - status: "unmatched", - reason: "no_exact_sanitized_url", - }, - source: { - included: true, - artifact: createWebTextArtifact(source, "text/javascript"), - }, - }, - ], - }, - resources: [], - network: { - requests: [], - websocket_connections: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - console: { - events: [], - coverage_started_at: "2026-07-14T00:00:00.000Z", - prior_activity_available: false, - }, - workers: [], - metadata: { - responses: [], - dom_urls: [], - agent_hints: [], - excluded_dom_urls: 0, - headers_allowlisted: true, - }, - storage: { - origin, - usage_bytes: null, - quota_bytes: null, - local_storage_keys: [], - session_storage_keys: [], - indexed_db_names: [], - cache_names: [], - values_redacted: true, - }, - limitations: [], - }); + ]); describe("per-script web bundle analysis", () => { it("keeps parser failures as analyzed outcomes with their own coverage", () => { diff --git a/tests/boundary/browser/cdpObservationWindowScope.test.ts b/tests/boundary/browser/cdpObservationWindowScope.test.ts index 9d5f44a965..c3b8e5f10c 100644 --- a/tests/boundary/browser/cdpObservationWindowScope.test.ts +++ b/tests/boundary/browser/cdpObservationWindowScope.test.ts @@ -2,17 +2,39 @@ import { expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; -import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; +import { + startFakeCdpBrowser, + type FakeOptions, +} from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; -it("fails closed on an untimestamped cross-origin main-frame commit during clock sampling", async () => { +const observeDuringClockSampling = async ( + commandEvents: NonNullable, +) => { const browser = await startFakeCdpBrowser({ eventsBeforeReply: ["Performance.getMetrics"], commandResult: (command) => command.method === "Performance.getMetrics" ? { metrics: [{ name: "Timestamp", value: 20 }] } : undefined, - commandEvents: (command) => { + commandEvents, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + observation_ms: 20, + }), + ); + if (!result.ok) throw result.error; + return { browser, observation: result.value }; +}; + +it("fails closed on an untimestamped cross-origin main-frame commit during clock sampling", async () => { + const { observation } = await observeDuringClockSampling( + (command, origin) => { if (command.method !== "Performance.getMetrics") return undefined; return [ { @@ -34,27 +56,17 @@ it("fails closed on an untimestamped cross-origin main-frame commit during clock timestamp: 21, type: "Document", frameId: "frame-main", - redirectResponse: { url: `${browser.allowedOrigin}/prior` }, - request: { url: `${browser.allowedOrigin}/later` }, + redirectResponse: { url: `${origin}/prior` }, + request: { url: `${origin}/later` }, }, }, ]; }, - }); - trackBrowser(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 20, - }), ); - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_left_scope"); - expect(result.value.target.final_url).toBeNull(); - expect(result.value.timeline).toContainEqual( + expect(observation.window.end_reason).toBe("target_left_scope"); + expect(observation.target.final_url).toBeNull(); + expect(observation.timeline).toContainEqual( expect.objectContaining({ type: "navigation_committed", loader_id: "outside-loader", @@ -63,19 +75,14 @@ it("fails closed on an untimestamped cross-origin main-frame commit during clock url: null, }), ); - expect(result.value.timeline).not.toContainEqual( + expect(observation.timeline).not.toContainEqual( expect.objectContaining({ request_id: "later-in-window-request" }), ); }); it("fails closed on an untimestamped out-of-scope main-frame request during clock sampling", async () => { - const browser = await startFakeCdpBrowser({ - eventsBeforeReply: ["Performance.getMetrics"], - commandResult: (command) => - command.method === "Performance.getMetrics" - ? { metrics: [{ name: "Timestamp", value: 20 }] } - : undefined, - commandEvents: (command, origin) => { + const { observation } = await observeDuringClockSampling( + (command, origin) => { if (command.method !== "Performance.getMetrics") return undefined; return [ { @@ -101,20 +108,10 @@ it("fails closed on an untimestamped out-of-scope main-frame request during cloc }, ]; }, - }); - trackBrowser(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 20, - }), ); - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_left_scope"); - expect(result.value.timeline).toContainEqual( + expect(observation.window.end_reason).toBe("target_left_scope"); + expect(observation.timeline).toContainEqual( expect.objectContaining({ type: "navigation_requested", timestamp: null, @@ -122,19 +119,14 @@ it("fails closed on an untimestamped out-of-scope main-frame request during cloc url: null, }), ); - expect(result.value.timeline).not.toContainEqual( + expect(observation.timeline).not.toContainEqual( expect.objectContaining({ request_id: "later-in-window-request" }), ); }); it("fails closed on an untimestamped out-of-scope redirect during clock sampling", async () => { - const browser = await startFakeCdpBrowser({ - eventsBeforeReply: ["Performance.getMetrics"], - commandResult: (command) => - command.method === "Performance.getMetrics" - ? { metrics: [{ name: "Timestamp", value: 20 }] } - : undefined, - commandEvents: (command, origin) => { + const { observation } = await observeDuringClockSampling( + (command, origin) => { if (command.method !== "Performance.getMetrics") return undefined; return [ { @@ -162,21 +154,11 @@ it("fails closed on an untimestamped out-of-scope redirect during clock sampling }, ]; }, - }); - trackBrowser(browser); - const result = await new CdpBrowserProvider().observeSession( - observeWebSessionInputSchema.parse({ - cdp_endpoint: browser.endpoint, - allowed_origins: [browser.allowedOrigin], - target_id: "allowed-page", - observation_ms: 20, - }), ); - if (!result.ok) throw result.error; - expect(result.value.window.end_reason).toBe("target_left_scope"); - expect(result.value.target.final_url).toBeNull(); - expect(result.value.timeline).toContainEqual( + expect(observation.window.end_reason).toBe("target_left_scope"); + expect(observation.target.final_url).toBeNull(); + expect(observation.timeline).toContainEqual( expect.objectContaining({ type: "redirect", request_id: "unknown-time-outside-redirect", @@ -185,7 +167,7 @@ it("fails closed on an untimestamped out-of-scope redirect during clock sampling url: null, }), ); - expect(result.value.timeline).not.toContainEqual( + expect(observation.timeline).not.toContainEqual( expect.objectContaining({ request_id: "later-in-window-request" }), ); }); diff --git a/tests/boundary/browser/cdpSvgXlinkHref.test.ts b/tests/boundary/browser/cdpSvgXlinkHref.test.ts index 38048861b6..7d1e2a6274 100644 --- a/tests/boundary/browser/cdpSvgXlinkHref.test.ts +++ b/tests/boundary/browser/cdpSvgXlinkHref.test.ts @@ -14,6 +14,7 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { processTest as it, waitForExit, + type TestProcesses, } from "../../support/process/processFixture.js"; const chromeExecutable = [ @@ -34,6 +35,87 @@ const chromeExecutable = [ const CHROME_STARTUP_TIMEOUT_MS = 60_000; const CHROME_TEST_TIMEOUT_MS = CHROME_STARTUP_TIMEOUT_MS + 30_000; +const inspectChromePage = async ({ + processes, + executable, + profilePrefix, + page, + contentType, + pathname, +}: { + processes: TestProcesses; + executable: string; + profilePrefix: string; + page: string; + contentType: string; + pathname: string; +}) => { + const profile = await createTestTempDirectory(profilePrefix); + const server = createServer((_request, response) => { + response.writeHead(200, { + "content-type": `${contentType}; charset=utf-8`, + }); + response.end(page); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("SVG fixture server did not bind a TCP port"); + const origin = `http://127.0.0.1:${String(address.port)}`; + const pageUrl = `${origin}${pathname}`; + let stderr = ""; + const child = processes.spawn(executable, [ + "--headless=new", + ...(process.env.REA_BROWSER_NO_SANDBOX === "true" ? ["--no-sandbox"] : []), + "--remote-debugging-address=127.0.0.1", + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + "--disable-component-update", + "--disable-dev-shm-usage", + "--disable-sync", + "--metrics-recording-only", + pageUrl, + ]); + child.stdout.on("data", () => undefined); + child.stderr.on("data", (chunk: Buffer) => { + if (stderr.length < 8_192) stderr += chunk.toString("utf8"); + }); + try { + const port = await waitForBrowserDevtoolsPort({ + child, + executable, + activePortPath: join(profile, "DevToolsActivePort"), + stderr: () => stderr, + timeoutMs: CHROME_STARTUP_TIMEOUT_MS, + }); + const endpoint = `http://127.0.0.1:${String(port)}`; + const provider = new CdpBrowserProvider(); + const targetId = await pageTarget(provider, endpoint, origin, pageUrl); + const result = await provider.inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: endpoint, + allowed_origins: [origin], + target_id: targetId, + observation_ms: 0, + }), + ); + if (!result.ok) throw result.error; + return { origin, pageUrl, inspection: result.value }; + } finally { + child.kill("SIGTERM"); + if (!(await waitForExit(child, 2_000))) child.kill("SIGKILL"); + await waitForExit(child, 2_000); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + } +}; + // Issue 1586's page, plus the HTML, image, use, and foreign-origin cases the // real snapshot must distinguish. The XHTML page is the prefixed-svg gap // reported on PR 1602. The tests are skipped when no Chrome-family executable @@ -84,133 +166,78 @@ it.skipIf(chromeExecutable === undefined)( async ({ processes }) => { const executable = chromeExecutable; if (executable === undefined) return; - const profile = await createTestTempDirectory("rea-svg-xlink-"); - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); - response.end(pageHtml); + const { origin, inspection } = await inspectChromePage({ + processes, + executable, + profilePrefix: "rea-svg-xlink-", + page: pageHtml, + contentType: "text/html", + pathname: "/x.html", }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (address === null || typeof address === "string") - throw new Error("SVG fixture server did not bind a TCP port"); - const origin = `http://127.0.0.1:${String(address.port)}`; - const pageUrl = `${origin}/x.html`; - let stderr = ""; - const child = processes.spawn(executable, [ - "--headless=new", - ...(process.env.REA_BROWSER_NO_SANDBOX === "true" - ? ["--no-sandbox"] - : []), - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - `--user-data-dir=${profile}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - "--disable-dev-shm-usage", - "--disable-sync", - "--metrics-recording-only", - pageUrl, - ]); - child.stdout.on("data", () => undefined); - child.stderr.on("data", (chunk: Buffer) => { - if (stderr.length < 8_192) stderr += chunk.toString("utf8"); - }); - try { - const port = await waitForBrowserDevtoolsPort({ - child, - executable, - activePortPath: join(profile, "DevToolsActivePort"), - stderr: () => stderr, - timeoutMs: CHROME_STARTUP_TIMEOUT_MS, - }); - const endpoint = `http://127.0.0.1:${String(port)}`; - const provider = new CdpBrowserProvider(); - const targetId = await pageTarget(provider, endpoint, origin, pageUrl); - const result = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: endpoint, - allowed_origins: [origin], - target_id: targetId, - observation_ms: 0, + const urls = inspection.metadata.dom_urls; + const html = inspection.dom.nodes.find( + (node) => + node.node_name === "A" && node.attribute_names.includes("xlink:href"), + ); + expect(html).toBeDefined(); + expect(urls.some((url) => url.node_index === html?.index)).toBe(false); + expect(urls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + attribute: "href", + url: `${origin}/xl-s2h`, + destination_scope: "approved", }), - ); - if (!result.ok) throw result.error; - const urls = result.value.metadata.dom_urls; - const html = result.value.dom.nodes.find( - (node) => - node.node_name === "A" && node.attribute_names.includes("xlink:href"), - ); - expect(html).toBeDefined(); - expect(urls.some((url) => url.node_index === html?.index)).toBe(false); - expect(urls).toEqual( - expect.arrayContaining([ + expect.objectContaining({ + attribute: "href", + url: `${origin}/href-wins`, + destination_scope: "approved", + }), + expect.objectContaining({ + attribute: "href", + url: `${origin}/xl-image`, + destination_scope: "approved", + }), + expect.objectContaining({ + attribute: "href", + url: `${origin}/xl-use`, + destination_scope: "approved", + }), + expect.objectContaining({ + attribute: "href", + url: null, + destination_scope: "outside_policy", + }), + ...[ + "/html-desc", + "/html-title", + "/xl-nested-desc", + "/xl-nested-title", + "/xl-moved-desc", + "/xl-moved-title", + "/xl-created-desc", + "/xl-created-title", + "/xl-created-nested-desc", + "/xl-created-nested-title", + ].map((path) => expect.objectContaining({ attribute: "href", - url: `${origin}/xl-s2h`, + url: `${origin}${path}`, destination_scope: "approved", }), - expect.objectContaining({ - attribute: "href", - url: `${origin}/href-wins`, - destination_scope: "approved", - }), - expect.objectContaining({ - attribute: "href", - url: `${origin}/xl-image`, - destination_scope: "approved", - }), - expect.objectContaining({ - attribute: "href", - url: `${origin}/xl-use`, - destination_scope: "approved", - }), - expect.objectContaining({ - attribute: "href", - url: null, - destination_scope: "outside_policy", - }), - ...[ - "/html-desc", - "/html-title", - "/xl-nested-desc", - "/xl-nested-title", - "/xl-moved-desc", - "/xl-moved-title", - "/xl-created-desc", - "/xl-created-title", - "/xl-created-nested-desc", - "/xl-created-nested-title", - ].map((path) => - expect.objectContaining({ - attribute: "href", - url: `${origin}${path}`, - destination_scope: "approved", - }), - ), - ]), - ); - expect( - urls.some( - (url) => - url.url?.includes("/xl-html") === true || - url.url?.includes("/xl-math") === true || - url.url?.endsWith("/xlink-loses") === true, ), - ).toBe(false); - expect(urls.every((url) => url.attribute === "href")).toBe(true); - expect(urls).toHaveLength(15); - } finally { - child.kill("SIGTERM"); - if (!(await waitForExit(child, 2_000))) child.kill("SIGKILL"); - await waitForExit(child, 2_000); - await new Promise((resolve) => { - server.close(() => resolve()); - }); - } + ]), + ); + expect( + urls.some( + (url) => + url.url?.includes("/xl-html") === true || + url.url?.includes("/xl-math") === true || + url.url?.endsWith("/xlink-loses") === true, + ), + ).toBe(false); + expect(urls.every((url) => url.attribute === "href")).toBe(true); + expect(urls).toHaveLength(15); }, CHROME_TEST_TIMEOUT_MS, ); @@ -277,99 +304,42 @@ for (const { format, page, contentType, nodeNames, link, destinations } of [ async ({ processes }) => { const executable = chromeExecutable; if (executable === undefined) return; - const profile = await createTestTempDirectory("rea-svg-xhtml-"); - const server = createServer((_request, response) => { - response.writeHead(200, { - "content-type": `${contentType}; charset=utf-8`, - }); - response.end(page); + const { origin, pageUrl, inspection } = await inspectChromePage({ + processes, + executable, + profilePrefix: "rea-svg-xhtml-", + page, + contentType, + pathname: "/page.xhtml", }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (address === null || typeof address === "string") - throw new Error("XHTML fixture server did not bind a TCP port"); - const origin = `http://127.0.0.1:${String(address.port)}`; - const pageUrl = `${origin}/page.xhtml`; - let stderr = ""; - const child = processes.spawn(executable, [ - "--headless=new", - ...(process.env.REA_BROWSER_NO_SANDBOX === "true" - ? ["--no-sandbox"] - : []), - "--remote-debugging-address=127.0.0.1", - "--remote-debugging-port=0", - `--user-data-dir=${profile}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-background-networking", - "--disable-component-update", - "--disable-dev-shm-usage", - "--disable-sync", - "--metrics-recording-only", - pageUrl, - ]); - child.stdout.on("data", () => undefined); - child.stderr.on("data", (chunk: Buffer) => { - if (stderr.length < 8_192) stderr += chunk.toString("utf8"); - }); - try { - const port = await waitForBrowserDevtoolsPort({ - child, - executable, - activePortPath: join(profile, "DevToolsActivePort"), - stderr: () => stderr, - timeoutMs: CHROME_STARTUP_TIMEOUT_MS, - }); - const endpoint = `http://127.0.0.1:${String(port)}`; - const provider = new CdpBrowserProvider(); - const targetId = await pageTarget(provider, endpoint, origin, pageUrl); - const result = await provider.inspectPage( - inspectWebPageInputSchema.parse({ - cdp_endpoint: endpoint, - allowed_origins: [origin], - target_id: targetId, - observation_ms: 0, - }), - ); - if (!result.ok) throw result.error; - const urls = result.value.metadata.dom_urls; - for (const name of nodeNames) { - expect( - result.value.dom.nodes.some((node) => node.node_name === name), - ).toBe(true); - } + const urls = inspection.metadata.dom_urls; + for (const name of nodeNames) { expect( - result.value.dom.nodes.some( - (node) => - node.node_name === link.name && - node.attribute_names.includes(link.attribute), - ), + inspection.dom.nodes.some((node) => node.node_name === name), ).toBe(true); - expect(urls).toEqual( - destinations.map((destination) => - expect.objectContaining({ - attribute: "href", - url: - destination === null - ? null - : destination === "" - ? pageUrl - : `${origin}${destination}`, - destination_scope: - destination === null ? "outside_policy" : "approved", - }), - ), - ); - } finally { - child.kill("SIGTERM"); - if (!(await waitForExit(child, 2_000))) child.kill("SIGKILL"); - await waitForExit(child, 2_000); - await new Promise((resolve) => { - server.close(() => resolve()); - }); } + expect( + inspection.dom.nodes.some( + (node) => + node.node_name === link.name && + node.attribute_names.includes(link.attribute), + ), + ).toBe(true); + expect(urls).toEqual( + destinations.map((destination) => + expect.objectContaining({ + attribute: "href", + url: + destination === null + ? null + : destination === "" + ? pageUrl + : `${origin}${destination}`, + destination_scope: + destination === null ? "outside_policy" : "approved", + }), + ), + ); }, CHROME_TEST_TIMEOUT_MS, ); diff --git a/tests/boundary/cli/apktoolBooleanOptions.test.ts b/tests/boundary/cli/apktoolBooleanOptions.test.ts index 5c5578c18a..9da4353112 100644 --- a/tests/boundary/cli/apktoolBooleanOptions.test.ts +++ b/tests/boundary/cli/apktoolBooleanOptions.test.ts @@ -1,51 +1,9 @@ import { expect, it } from "vitest"; -import { Cli } from "incur"; -import pino from "pino"; -import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; -import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import { registerApktoolCommands } from "../../../src/cli/apktoolCommands.js"; -import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js"; -import type { ApktoolRequest } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; -import { ok } from "../../../src/domain/result.js"; - -const invoke = async (argv: readonly string[]) => { - const requests: ApktoolRequest[] = []; - const provider: ApktoolResourceAnalysisPort = { - async inspectAvailability() { - throw new Error("unexpected provider probe"); - }, - async close() {}, - async execute(request) { - requests.push(request); - return ok( - createAnalysisExecution( - { strings: [] }, - { id: "apktool", name: "Apktool", version: null }, - ), - ); - }, - }; - const cli = Cli.create("rea", { sync: false }); - registerApktoolCommands(cli, pino({ level: "silent" }), {}, provider); - presentOmittableDefaults(cli); - - let stdout = ""; - let exitCode = 0; - await cli.serve([...argv], { - env: {}, - exit: (code) => { - exitCode = code; - }, - stdout: (text) => { - stdout += text; - }, - }); - return { exitCode, stdout, requests }; -}; +import { invokeApktoolCli } from "../../support/cli/apktoolCli.js"; it("dispatches --no-strings as include_strings false", async () => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--no-strings", @@ -62,7 +20,7 @@ it("dispatches --no-strings as include_strings false", async () => { }); it("keeps string projection enabled when the flag is omitted", async () => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--json", @@ -78,7 +36,7 @@ it("keeps string projection enabled when the flag is omitted", async () => { }); it("documents the supported negative flag in command help", async () => { - const result = await invoke(["decode-android-resources", "--help"]); + const result = await invokeApktoolCli(["decode-android-resources", "--help"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("--no-strings"); diff --git a/tests/boundary/cli/apktoolLocaleOption.test.ts b/tests/boundary/cli/apktoolLocaleOption.test.ts index 941b786cbc..65400be0b9 100644 --- a/tests/boundary/cli/apktoolLocaleOption.test.ts +++ b/tests/boundary/cli/apktoolLocaleOption.test.ts @@ -1,48 +1,6 @@ import { expect, it } from "vitest"; -import { Cli } from "incur"; -import pino from "pino"; -import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; -import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import { registerApktoolCommands } from "../../../src/cli/apktoolCommands.js"; -import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js"; -import type { ApktoolRequest } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; -import { ok } from "../../../src/domain/result.js"; - -const invoke = async (argv: readonly string[]) => { - const requests: ApktoolRequest[] = []; - const provider: ApktoolResourceAnalysisPort = { - async inspectAvailability() { - throw new Error("unexpected provider probe"); - }, - async close() {}, - async execute(request) { - requests.push(request); - return ok( - createAnalysisExecution( - { strings: [] }, - { id: "apktool", name: "Apktool", version: null }, - ), - ); - }, - }; - const cli = Cli.create("rea", { sync: false }); - registerApktoolCommands(cli, pino({ level: "silent" }), {}, provider); - presentOmittableDefaults(cli); - - let stdout = ""; - let exitCode = 0; - await cli.serve([...argv], { - env: {}, - exit: (code) => { - exitCode = code; - }, - stdout: (text) => { - stdout += text; - }, - }); - return { exitCode, stdout, requests }; -}; +import { invokeApktoolCli } from "../../support/cli/apktoolCli.js"; /** * Locales the apktool input owner admits. The owner's own rejection message @@ -64,7 +22,7 @@ const OWNER_ADMITTED_LOCALES = [ ] as const; it("forwards the table-only string choice through the CLI", async () => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--string-tables-only", @@ -88,7 +46,7 @@ it("forwards the table-only string choice through the CLI", async () => { it("forwards every locale the apktool input owner admits", async () => { for (const locale of OWNER_ADMITTED_LOCALES) { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--locale", @@ -107,7 +65,7 @@ it("forwards every locale the apktool input owner admits", async () => { }); it("forwards repeated --locales to project multiple localized tables", async () => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--locales", @@ -133,7 +91,7 @@ it("forwards repeated --locales to project multiple localized tables", async () it.each(["not a locale", "en-r419", "es-r419"])( "refuses the invalid locale %s before invoking Apktool", async (locale) => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--locale", @@ -151,7 +109,7 @@ it.each(["not a locale", "en-r419", "es-r419"])( ); it("forwards --no-strings with --locale preserving caller options", async () => { - const result = await invoke([ + const result = await invokeApktoolCli([ "decode-android-resources", "/targets/app.apk", "--no-strings", diff --git a/tests/support/browser/webPageInspection.ts b/tests/support/browser/webPageInspection.ts new file mode 100644 index 0000000000..e14124af0f --- /dev/null +++ b/tests/support/browser/webPageInspection.ts @@ -0,0 +1,122 @@ +import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js"; +import { createWebTextArtifact } from "../../../src/domain/webContentArtifact.js"; + +export interface WebPageScriptFixture { + source: string; + scriptKey: string; + url: string; + origin: string; + cdpHash: string; +} + +const origin = "https://app.example.test"; + +export const createWebPageInspection = ( + scripts: readonly WebPageScriptFixture[], +) => + webPageInspectionSchema.parse({ + browser: { + product: "Fake Chrome", + protocol_version: "1.3", + revision: "1", + user_agent: "fake", + js_version: "1", + }, + target: { + target_id: "page-1", + type: "page", + title: "App", + url: `${origin}/app`, + origin, + attached: false, + }, + capture_window: { + started_at: "2026-07-14T00:00:00.000Z", + ended_at: "2026-07-14T00:00:01.000Z", + observation_ms: 1_000, + }, + completeness: { + status: "attach_limited", + conditions: ["attach_limited"], + policy_filtered_sections: [], + attach_limited_sections: ["network_requests"], + truncated_sections: [], + unavailable_sections: [], + excluded: [], + dropped_events: { + scripts: 0, + network_requests: 0, + console_events: 0, + websocket_connections: 0, + websocket_frames: 0, + webmcp_tools: 0, + timeline_events: 0, + total: 0, + }, + }, + frames: [], + dom: { total_nodes: 0, nodes: [] }, + accessibility: { + total_nodes: 0, + text_capture: { + status: "not_approved", + retained_bytes: 0, + excluded_fields: 0, + }, + nodes: [], + }, + scripts: { + total: scripts.length, + items: scripts.map( + ({ source, scriptKey, url, origin: scriptOrigin, cdpHash }) => ({ + script_key: scriptKey, + url, + origin: scriptOrigin, + cdp_hash: cdpHash, + length: Buffer.byteLength(source), + is_module: true, + language: "JavaScript", + source_map_url: null, + resource_reconciliation: { + status: "unmatched", + reason: "no_exact_sanitized_url", + }, + source: { + included: true, + artifact: createWebTextArtifact(source, "text/javascript"), + }, + }), + ), + }, + resources: [], + network: { + requests: [], + websocket_connections: [], + coverage_started_at: "2026-07-14T00:00:00.000Z", + prior_activity_available: false, + }, + console: { + events: [], + coverage_started_at: "2026-07-14T00:00:00.000Z", + prior_activity_available: false, + }, + workers: [], + metadata: { + responses: [], + dom_urls: [], + agent_hints: [], + excluded_dom_urls: 0, + headers_allowlisted: true, + }, + storage: { + origin, + usage_bytes: null, + quota_bytes: null, + local_storage_keys: [], + session_storage_keys: [], + indexed_db_names: [], + cache_names: [], + values_redacted: true, + }, + limitations: [], + }); diff --git a/tests/support/cli/apktoolCli.ts b/tests/support/cli/apktoolCli.ts new file mode 100644 index 0000000000..cb3570ef60 --- /dev/null +++ b/tests/support/cli/apktoolCli.ts @@ -0,0 +1,44 @@ +import { Cli } from "incur"; +import pino from "pino"; + +import type { ApktoolResourceAnalysisPort } from "../../../src/application/apktool/ApktoolResourceAnalysisPort.js"; +import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; +import { registerApktoolCommands } from "../../../src/cli/apktoolCommands.js"; +import { presentOmittableDefaults } from "../../../src/cli/omittableDefaults.js"; +import type { ApktoolRequest } from "../../../src/domain/apktool/apktoolResourceAnalysis.js"; +import { ok } from "../../../src/domain/result.js"; + +export const invokeApktoolCli = async (argv: readonly string[]) => { + const requests: ApktoolRequest[] = []; + const provider: ApktoolResourceAnalysisPort = { + async inspectAvailability() { + throw new Error("unexpected provider probe"); + }, + async close() {}, + async execute(request) { + requests.push(request); + return ok( + createAnalysisExecution( + { strings: [] }, + { id: "apktool", name: "Apktool", version: null }, + ), + ); + }, + }; + const cli = Cli.create("rea", { sync: false }); + registerApktoolCommands(cli, pino({ level: "silent" }), {}, provider); + presentOmittableDefaults(cli); + + let stdout = ""; + let exitCode = 0; + await cli.serve([...argv], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (text) => { + stdout += text; + }, + }); + return { exitCode, stdout, requests }; +};