From a0deee29a3bc5eaa2da6a89dec8b06b6e8196fee Mon Sep 17 00:00:00 2001 From: shadowusr Date: Wed, 13 May 2026 17:59:22 +0300 Subject: [PATCH 1/2] feat: implement the time-travel-snapshot tool --- packages/cli/package.json | 1 + packages/mcp/package.json | 1 + packages/mcp/test/server.e2e.test.ts | 3 +- packages/tools/package.json | 3 + packages/tools/src/index.ts | 2 + .../tools/src/responses/browser-helpers.ts | 39 +- packages/tools/src/responses/index.ts | 28 +- .../tools/time-travel-snapshot/formatters.ts | 137 +++ .../src/tools/time-travel-snapshot/index.ts | 217 +++++ .../time-travel-snapshot/render-server.ts | 211 +++++ .../src/tools/time-travel-snapshot/report.ts | 184 ++++ .../time-travel-snapshot/rrweb-snapshots.ts | 157 ++++ .../src/tools/time-travel-snapshot/schema.ts | 93 ++ .../time-travel-snapshot/snapshot-diff.ts | 854 ++++++++++++++++++ .../src/tools/time-travel-snapshot/types.ts | 23 + packages/tools/test/responses/index.test.ts | 127 ++- packages/tools/test/setup.ts | 2 +- .../tools/test/tools/click-on-element.test.ts | 4 +- .../tools/test/tools/hover-element.test.ts | 4 +- packages/tools/test/tools/navigate.test.ts | 2 +- .../test/tools/time-travel-snapshot.test.ts | 355 ++++++++ 21 files changed, 2374 insertions(+), 73 deletions(-) create mode 100644 packages/tools/src/tools/time-travel-snapshot/formatters.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/index.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/render-server.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/report.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/rrweb-snapshots.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/schema.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/snapshot-diff.ts create mode 100644 packages/tools/src/tools/time-travel-snapshot/types.ts create mode 100644 packages/tools/test/tools/time-travel-snapshot.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 39d9a57..b84bfa1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,6 +26,7 @@ "license": "ISC", "dependencies": { "@testplane/testing-library": "^1.0.5", + "@rrweb/replay": "^2.0.0-alpha.18", "commander": "^14.0.3", "debug": "^4.3.4", "html-reporter": "^11.10.0-rc.2", diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 67ca387..b512cd4 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -27,6 +27,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.11.2", "@testplane/testing-library": "^1.0.5", + "@rrweb/replay": "^2.0.0-alpha.18", "commander": "^13.1.0", "html-reporter": "^11.10.0-rc.2", "testplane": "^8.44.1-rc.1", diff --git a/packages/mcp/test/server.e2e.test.ts b/packages/mcp/test/server.e2e.test.ts index 547cd73..fef6b5b 100644 --- a/packages/mcp/test/server.e2e.test.ts +++ b/packages/mcp/test/server.e2e.test.ts @@ -23,6 +23,7 @@ const EXPECTED_TOOL_NAMES = [ // report tools "test-results", "inspect-result", + "time-travel-snapshot", // session tools "launch", "attach", @@ -61,7 +62,7 @@ describe( const text = content.map(c => c.text).join("\n"); expect(text).toContain(`Successfully navigated to ${playgroundUrl}`); - const snapshotPathMatch = text.match(/Saved to: (\S+\.(?:yml|html))/); + const snapshotPathMatch = text.match(/(?:Saved to:|The snapshot was saved to:) (\S+\.(?:yml|html))/); expect(snapshotPathMatch, "navigate response should reference a saved snapshot file").not.toBeNull(); const snapshotContent = fs.readFileSync(snapshotPathMatch![1], "utf8"); expect(snapshotContent).toContain("server-wiring-ok"); diff --git a/packages/tools/package.json b/packages/tools/package.json index dcbaa97..c136e9b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -28,6 +28,9 @@ "license": "ISC", "dependencies": { "@testplane/testing-library": "^1.0.5", + "@rrweb/replay": "^2.0.0-alpha.18", + "@rrweb/types": "^2.0.0-alpha.18", + "fflate": "^0.8.2", "html-reporter": "^11.10.0-rc.2", "lodash.escaperegexp": "^4.1.2", "testplane": "^8.44.1-rc.1", diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index ff0990c..4178825 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -17,6 +17,7 @@ import { attachToBrowser } from "./tools/attach-to-browser.js"; import { closeBrowser } from "./tools/close-browser.js"; import { inspectResult } from "./tools/inspect-result/index.js"; import { testResults } from "./tools/test-results/index.js"; +import { timeTravelSnapshot } from "./tools/time-travel-snapshot/index.js"; // This function just ensures that every item on a type level is a Tool. AFAIK the only way to do this. const typeCheckedTools = ( @@ -42,6 +43,7 @@ export const tools = typeCheckedTools([ closeBrowser, testResults, inspectResult, + timeTravelSnapshot, ]); export { launchBrowserWithOptions, ToolKind }; diff --git a/packages/tools/src/responses/browser-helpers.ts b/packages/tools/src/responses/browser-helpers.ts index 5434a81..7a41060 100644 --- a/packages/tools/src/responses/browser-helpers.ts +++ b/packages/tools/src/responses/browser-helpers.ts @@ -131,7 +131,13 @@ function formatNotesAsComments(notes: string[], fenceLanguage: "yaml" | "html"): export interface PageSnapshotResult { content: string; - fenceLanguage: "yaml" | "html"; + fenceLanguage: "yaml" | "html" | "diff"; +} + +export const INLINE_SNAPSHOT_MAX_LENGTH = 32_000; + +export function isPageSnapshotTooLargeForInline(snapshot: PageSnapshotResult): boolean { + return snapshot.content.length > INLINE_SNAPSHOT_MAX_LENGTH; } export async function getPageSnapshot( @@ -152,21 +158,34 @@ export interface SavedPageSnapshot { filePath: string; } -export async function savePageSnapshotToFile( - browser: WdioBrowser, - options: CaptureSnapshotOptions = {}, -): Promise { - const result = await getPageSnapshot(browser, options); - if (!result) return null; - +async function savePageSnapshotToFile(snapshot: PageSnapshotResult): Promise { const dir = path.join(os.tmpdir(), ".testplane", "snapshots"); await fs.mkdir(dir, { recursive: true }); const timestamp = formatTimestamp(); - const extension = result.fenceLanguage === "html" ? "html" : "yml"; + const extension = snapshot.fenceLanguage === "html" ? "html" : snapshot.fenceLanguage === "diff" ? "diff" : "yml"; const filePath = path.join(dir, `${timestamp}-${randomUUID()}.${extension}`); - await fs.writeFile(filePath, result.content, "utf8"); + await fs.writeFile(filePath, snapshot.content, "utf8"); return { filePath }; } + +/** Format a snapshot as a response string, optionally saving it to a file if it is too large for inline display. */ +export async function convertSnapshotToResponse( + snapshot: PageSnapshotResult, + { forceSaveToFile = false }: { forceSaveToFile?: boolean } = {}, +): Promise { + if (forceSaveToFile) { + const saved = await savePageSnapshotToFile(snapshot); + return `The snapshot was saved to: ${saved.filePath}`; + } + + if (!isPageSnapshotTooLargeForInline(snapshot)) { + return "```" + snapshot.fenceLanguage + "\n" + snapshot.content + "\n```"; + } + + const saved = await savePageSnapshotToFile(snapshot); + + return `The snapshot is too large to include inline (${snapshot.content.length} characters; limit ${INLINE_SNAPSHOT_MAX_LENGTH}), so it was saved to: ${saved.filePath}`; +} diff --git a/packages/tools/src/responses/index.ts b/packages/tools/src/responses/index.ts index 1e4172d..31abf7d 100644 --- a/packages/tools/src/responses/index.ts +++ b/packages/tools/src/responses/index.ts @@ -1,6 +1,11 @@ import { WdioBrowser } from "testplane"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { CaptureSnapshotOptions, getPageSnapshot, getBrowserTabs, savePageSnapshotToFile } from "./browser-helpers.js"; +import { + CaptureSnapshotOptions, + getPageSnapshot, + getBrowserTabs, + convertSnapshotToResponse, +} from "./browser-helpers.js"; export type ToolResponse = CallToolResult; @@ -55,21 +60,14 @@ export async function createBrowserStateResponse( } if (options.isSnapshotNeeded !== false) { - if (options.inlineSnapshot) { - const snapshot = await getPageSnapshot(browser, options.snapshotOptions); - if (snapshot) { - sections.push("## Current Tab Snapshot"); - sections.push("```" + snapshot.fenceLanguage + "\n" + snapshot.content + "\n```"); - } + const snapshot = await getPageSnapshot(browser, options.snapshotOptions); + if (!snapshot) { + sections.push("## Current Tab Snapshot"); + sections.push("No snapshot captured"); } else { - const saved = await savePageSnapshotToFile(browser, options.snapshotOptions); - if (saved) { - sections.push("## Current Tab Snapshot"); - sections.push( - `Saved to: ${saved.filePath}\n` + - `Note: some tags/attributes may be omitted and text may be truncated — see the comments at the top of the file for details.`, - ); - } + const response = await convertSnapshotToResponse(snapshot, { forceSaveToFile: !options.inlineSnapshot }); + sections.push("## Current Tab Snapshot"); + sections.push(response); } } diff --git a/packages/tools/src/tools/time-travel-snapshot/formatters.ts b/packages/tools/src/tools/time-travel-snapshot/formatters.ts new file mode 100644 index 0000000..a76478a --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/formatters.ts @@ -0,0 +1,137 @@ +import { ReporterTestResult } from "html-reporter/experimental/sdk"; +import { TimeTravelArchive } from "./rrweb-snapshots.js"; +import { TimeTravelSnapshotArgs } from "./schema.js"; +import { ReporterTestStep, SelectedSnapshotTime, SnapshotInputSelection } from "./types.js"; + +function formatOffset(offsetMs: number): string { + return offsetMs >= 0 ? `+${offsetMs}ms` : `${offsetMs}ms`; +} + +function formatStepTime(step: ReporterTestStep, snapshotStartTime: number): string { + const startOffset = Math.round(step.ts - snapshotStartTime); + const endOffset = Math.round(step.ts + step.d - snapshotStartTime); + + return `${formatOffset(startOffset)}..${formatOffset(endOffset)}`; +} + +function formatStepArg(arg: string): string { + const singleLineArg = arg.replace(/\s+/g, " ").trim(); + const truncatedArg = singleLineArg.length > 60 ? `${singleLineArg.slice(0, 57)}...` : singleLineArg; + + return JSON.stringify(truncatedArg); +} + +function formatStepCall(step: ReporterTestStep): string { + const args = step.a.length > 0 ? `(${step.a.map(formatStepArg).join(", ")})` : ""; + + return `${step.n}${args}`; +} + +function formatStepLine(step: ReporterTestStep, snapshotStartTime: number, depth: number): string { + const marker = step.f ? "!" : "-"; + const indent = " ".repeat(depth); + + return `${indent}${marker} ${formatStepTime(step, snapshotStartTime)} ${formatStepCall(step)}`; +} + +function formatStepTree(step: ReporterTestStep, snapshotStartTime: number, depth: number): string[] { + return [ + formatStepLine(step, snapshotStartTime, depth), + ...(step.c ?? []).flatMap(child => formatStepTree(child, snapshotStartTime, depth + 1)), + ]; +} + +export function formatReportTestSteps(result: ReporterTestResult, snapshotStartTime: number): string | undefined { + if (!result.history?.length) { + return undefined; + } + + const lines = ['Times are offsets from the first rrweb event; use them as "time" values.']; + + for (const step of result.history) { + if (step.f) { + lines.push(...formatStepTree(step, snapshotStartTime, 0)); + continue; + } + + lines.push(formatStepLine(step, snapshotStartTime, 0)); + } + + return lines.join("\n"); +} + +function formatSelectedTime(selection: SelectedSnapshotTime): string { + const lines = [ + `Reason: ${selection.reason}`, + `Absolute timestamp: ${selection.absoluteTime} (${new Date(selection.absoluteTime).toISOString()})`, + `Offset from first rrweb event: ${selection.offsetMs}ms`, + ]; + + if (selection.requestedTime !== undefined) { + lines.push(`Requested time: ${selection.requestedTime} (${selection.requestedKind})`); + } + + if (selection.wasClamped && selection.unclampedTime !== undefined) { + lines.push( + `Unclamped timestamp: ${selection.unclampedTime} (${new Date(selection.unclampedTime).toISOString()})`, + ); + } + + return lines.join("\n"); +} + +function formatSourceInfo( + args: TimeTravelSnapshotArgs, + input: SnapshotInputSelection, + archive: TimeTravelArchive, +): string { + const lines = [ + `Mode: ${input.mode}`, + `Snapshot source: ${archive.source}`, + `Events: ${archive.events.length}`, + `Snapshot range: ${archive.metadata.startTime} (${new Date(archive.metadata.startTime).toISOString()}) - ${archive.metadata.endTime} (${new Date(archive.metadata.endTime).toISOString()}); total ${archive.metadata.totalTime}ms`, + ]; + + if (input.result) { + lines.unshift( + `Report: ${args.report}`, + `Test: ${input.result.fullName}`, + `Browser: ${input.result.browserId}`, + `Attempt: ${input.result.attempt}`, + ); + } + + return lines.join("\n"); +} + +export function formatResponse( + args: TimeTravelSnapshotArgs, + input: SnapshotInputSelection, + archive: TimeTravelArchive, + selectedTime: SelectedSnapshotTime, + diffFromTime: SelectedSnapshotTime | undefined, + snapshotOutput: string, +): string { + const sections = [ + "Time travel snapshot captured", + "## Source", + formatSourceInfo(args, input, archive), + "## Selected Time", + formatSelectedTime(selectedTime), + ]; + + if (diffFromTime) { + sections.push("## Diff From", formatSelectedTime(diffFromTime)); + } + + if (input.result) { + const testSteps = formatReportTestSteps(input.result, archive.metadata.startTime); + if (testSteps) { + sections.push("## Test Steps", testSteps); + } + } + + sections.push(diffFromTime ? "## Snapshot Diff" : "## Snapshot", snapshotOutput); + + return sections.join("\n\n"); +} diff --git a/packages/tools/src/tools/time-travel-snapshot/index.ts b/packages/tools/src/tools/time-travel-snapshot/index.ts new file mode 100644 index 0000000..8d24376 --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/index.ts @@ -0,0 +1,217 @@ +import { readResultsFromReport } from "html-reporter/experimental/sdk"; +import type { WdioBrowser } from "testplane"; +import { createErrorResponse, createSimpleResponse } from "../../responses/index.js"; +import { + type CaptureSnapshotOptions, + getPageSnapshot, + type PageSnapshotResult, + convertSnapshotToResponse, +} from "../../responses/browser-helpers.js"; +import { StandaloneTool, ToolKind } from "../../types.js"; +import { downloadReportIfNeeded } from "../../utils/html-report.js"; +import { launchBrowserWithOptions } from "../launch-browser.js"; +import { loadTimeTravelArchive, type TimeTravelArchive, resolveTargetTime } from "./rrweb-snapshots.js"; +import { + findReportTestResult, + getReportDefaultTime, + getSnapshotAttachment, + resolveSnapshotAttachmentSource, +} from "./report.js"; +import { startTimeTravelRenderServer, type TimeTravelRenderServer } from "./render-server.js"; +import { timeTravelSnapshotObjectSchema, timeTravelSnapshotSchema, type TimeTravelSnapshotArgs } from "./schema.js"; +import { diffPageSnapshots } from "./snapshot-diff.js"; +import { SelectedSnapshotTime, SnapshotInputSelection } from "./types.js"; +import { formatResponse } from "./formatters.js"; + +export { timeTravelSnapshotObjectSchema, timeTravelSnapshotSchema } from "./schema.js"; +export { loadTimeTravelArchive as loadRrwebSnapshotArchive, resolveTargetTime } from "./rrweb-snapshots.js"; +export { + findReportTestResult, + getReportDefaultTime, + getSnapshotAttachment, + resolveSnapshotAttachmentSource, +} from "./report.js"; +export { diffPageSnapshots } from "./snapshot-diff.js"; + +const RENDER_TIMEOUT_MS = 15_000; + +function getSnapshotOptions(args: TimeTravelSnapshotArgs): CaptureSnapshotOptions { + return { + includeTags: args.includeTags, + includeAttrs: args.includeAttrs, + excludeTags: args.excludeTags, + excludeAttrs: args.excludeAttrs, + truncateText: args.truncateText, + maxTextLength: args.maxTextLength, + }; +} + +async function getSnapshotInput(args: TimeTravelSnapshotArgs): Promise { + if (args.snapshotFile) { + return { + mode: "direct", + source: args.snapshotFile, + }; + } + + const reportPath = await downloadReportIfNeeded(args.report!); + const results = await readResultsFromReport(reportPath); + const result = findReportTestResult(results, { + name: args.name!, + browser: args.browser!, + attempt: args.attempt, + }); + const attachment = getSnapshotAttachment(result); + const source = await resolveSnapshotAttachmentSource(args.report!, reportPath, attachment.path); + + return { + mode: "report", + source, + result, + defaultTime: getReportDefaultTime(result), + }; +} + +function getWindowSize(archive: TimeTravelArchive): { width: number; height: number } { + return { + width: Math.min(Math.max(Math.ceil(archive.metadata.width ?? 1280), 800), 1920), + height: Math.min(Math.max(Math.ceil(archive.metadata.height ?? 720), 600), 1080), + }; +} + +async function waitForRender(browser: WdioBrowser): Promise { + let renderError: string | undefined; + + await browser.waitUntil( + async () => { + const status = (await browser.execute(() => { + const root = document.documentElement; + + return { + ready: root.dataset.timeTravelReady === "true", + error: root.dataset.timeTravelError, + }; + })) as { ready: boolean; error?: string }; + + renderError = status.error; + + return status.ready || Boolean(status.error); + }, + { + timeout: RENDER_TIMEOUT_MS, + interval: 100, + timeoutMsg: `Time travel snapshot renderer did not become ready within ${RENDER_TIMEOUT_MS}ms.`, + }, + ); + + if (renderError) { + throw new Error(`Time travel snapshot renderer failed: ${renderError}`); + } +} + +async function captureRenderedSnapshot( + archive: TimeTravelArchive, + selectedTime: SelectedSnapshotTime, + snapshotOptions: CaptureSnapshotOptions, +): Promise { + let server: TimeTravelRenderServer | null = null; + let browser: WdioBrowser | null = null; + + try { + server = await startTimeTravelRenderServer(archive.events, selectedTime.offsetMs); + browser = await launchBrowserWithOptions({ + headless: true, + windowSize: getWindowSize(archive), + }); + + await browser.openAndWait(server.url, { ignoreNetworkErrorsPatterns: [/.*/], timeout: RENDER_TIMEOUT_MS }); + await waitForRender(browser); + + const iframe = await browser.$('iframe[data-time-travel-target="true"]'); + await iframe.waitForExist({ timeout: 5_000 }); + await browser.switchFrame(iframe); + + const snapshot = await getPageSnapshot(browser, snapshotOptions); + if (!snapshot) { + throw new Error("Failed to capture DOM snapshot from rrweb iframe."); + } + + return snapshot; + } finally { + if (browser) { + try { + await browser.switchFrame(null); + } catch { + // The browser may already be closed or not inside a frame. + } + + try { + await browser.deleteSession(); + } catch (error) { + console.error("Error closing time travel snapshot browser:", error); + } + } + + if (server) { + try { + await server.close(); + } catch (error) { + console.error("Error closing time travel snapshot render server:", error); + } + } + } +} + +const timeTravelSnapshotCb: StandaloneTool["cb"] = async rawArgs => { + try { + const args = timeTravelSnapshotObjectSchema.parse(rawArgs); + const input = await getSnapshotInput(args); + const archive = await loadTimeTravelArchive(input.source); + const selectedTime = resolveTargetTime(archive.metadata, { + time: args.time, + defaultAbsoluteTime: input.defaultTime?.absoluteTime, + defaultReason: input.defaultTime?.reason, + }); + const snapshotOptions = getSnapshotOptions(args); + + const currentSnapshot = await captureRenderedSnapshot(archive, selectedTime, snapshotOptions); + let outputSnapshot = currentSnapshot; + + const diffFromTime = + args.diffFrom === undefined + ? undefined + : resolveTargetTime(archive.metadata, { + time: args.diffFrom, + }); + if (diffFromTime) { + const baselineSnapshot = await captureRenderedSnapshot(archive, diffFromTime, snapshotOptions); + outputSnapshot = diffPageSnapshots(baselineSnapshot, currentSnapshot); + } + const snapshotResponse = await convertSnapshotToResponse(outputSnapshot); + + return createSimpleResponse(formatResponse(args, input, archive, selectedTime, diffFromTime, snapshotResponse)); + } catch (error) { + console.error("Error capturing time travel snapshot:", error); + + return createErrorResponse("Error capturing time travel snapshot", error instanceof Error ? error : undefined); + } +}; + +export const timeTravelSnapshot: StandaloneTool = { + kind: ToolKind.Standalone, + name: "time-travel-snapshot", + description: + "Inspect Testplane Time Travel rrweb snapshot at a selected time and return a DOM snapshot of the replayed page, optionally with a diff from a previous time", + schema: timeTravelSnapshotSchema, + cb: timeTravelSnapshotCb, + cli: { + section: "Reports", + examples: [ + 'testplane-cli time-travel-snapshot /path/to/html-report --name "checkout submits order" --browser chrome', + 'testplane-cli time-travel-snapshot /path/to/html-report --name "checkout submits order" --browser chrome --time 250', + 'testplane-cli time-travel-snapshot /path/to/html-report --name "checkout submits order" --browser chrome --time 250 --diff-from 100', + "testplane-cli time-travel-snapshot --snapshot-file /path/to/snapshot.zip --time 100", + ], + positional: ["report"], + }, +}; diff --git a/packages/tools/src/tools/time-travel-snapshot/render-server.ts b/packages/tools/src/tools/time-travel-snapshot/render-server.ts new file mode 100644 index 0000000..d46aeef --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/render-server.ts @@ -0,0 +1,211 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type Server, type ServerResponse } from "node:http"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import type { NumberedRrwebEvent } from "./rrweb-snapshots.js"; + +export interface TimeTravelRenderServer { + url: string; + close: () => Promise; +} + +const require = createRequire(import.meta.url); + +function getRrwebReplayDistPath(fileName: string): string { + const replayEntry = require.resolve("@rrweb/replay"); + + return path.join(path.dirname(replayEntry), fileName); +} + +function sendText(response: ServerResponse, statusCode: number, contentType: string, body: string): void { + response.writeHead(statusCode, { + "Content-Type": contentType, + "Cache-Control": "no-store", + }); + response.end(body); +} + +async function sendFile(response: ServerResponse, contentType: string, filePath: string): Promise { + response.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "no-store", + }); + response.end(await readFile(filePath)); +} + +function createRendererHtml(offsetMs: number, token: string): string { + return ` + + + + Time Travel Snapshot + + + + +
+ + +`; +} + +function assertToken(url: URL, token: string): void { + if (url.searchParams.get("token") !== token) { + throw new Error("Invalid render server token."); + } +} + +async function handleRequest( + requestUrl: string | undefined, + token: string, + eventsJson: string, + offsetMs: number, + response: ServerResponse, +): Promise { + const url = new URL(requestUrl ?? "/", "http://127.0.0.1"); + + assertToken(url, token); + + if (url.pathname === "/") { + sendText(response, 200, "text/html; charset=utf-8", createRendererHtml(offsetMs, token)); + return; + } + + if (url.pathname === "/events") { + sendText(response, 200, "application/json; charset=utf-8", eventsJson); + return; + } + + if (url.pathname === "/rrweb/replay.js") { + await sendFile(response, "text/javascript; charset=utf-8", getRrwebReplayDistPath("replay.js")); + return; + } + + if (url.pathname === "/rrweb/style.css") { + await sendFile(response, "text/css; charset=utf-8", getRrwebReplayDistPath("style.css")); + return; + } + + sendText(response, 404, "text/plain; charset=utf-8", "Not found"); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close(error => { + if (error) { + reject(error); + return; + } + + resolve(); + }); + server.closeAllConnections?.(); + }); +} + +export async function startTimeTravelRenderServer( + events: readonly NumberedRrwebEvent[], + offsetMs: number, +): Promise { + const token = randomUUID(); + const eventsJson = JSON.stringify(events); + const server = createServer((request, response) => { + handleRequest(request.url, token, eventsJson, offsetMs, response).catch(error => { + sendText( + response, + 500, + "text/plain; charset=utf-8", + error instanceof Error ? error.message : String(error), + ); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${address.port}/?token=${token}`, + close: () => closeServer(server), + }; +} diff --git a/packages/tools/src/tools/time-travel-snapshot/report.ts b/packages/tools/src/tools/time-travel-snapshot/report.ts new file mode 100644 index 0000000..0938a7e --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/report.ts @@ -0,0 +1,184 @@ +import path from "node:path"; +import { stat } from "node:fs/promises"; +import type { ReporterTestResult } from "html-reporter/experimental/sdk"; +import { isRemoteSource } from "./rrweb-snapshots.js"; +import { ReporterTestStep } from "./types.js"; + +interface SnapshotAttachment { + type: 0 | "snapshot"; + path: string; + maxWidth?: number; + maxHeight?: number; +} + +export interface ReportResultSelection { + name: string; + browser: string; + attempt?: number; +} + +export interface ReportDefaultTime { + absoluteTime: number; + reason: string; +} + +function getLatestAttempt(results: readonly T[]): T { + return results.reduce((latest, result) => { + if ( + result.attempt > latest.attempt || + (result.attempt === latest.attempt && (result.timestamp ?? 0) >= (latest.timestamp ?? 0)) + ) { + return result; + } + + return latest; + }); +} + +function getSelectionError(results: readonly ReporterTestResult[], args: ReportResultSelection): string { + const resultsWithMatchingName = results.filter(result => result.fullName === args.name); + + if (!resultsWithMatchingName.length) { + return `No test result found with name "${args.name}". You can check what tests are available with the "test-results" tool.`; + } + + const resultsWithMatchingBrowser = resultsWithMatchingName.filter(result => result.browserId === args.browser); + if (!resultsWithMatchingBrowser.length) { + const availableBrowsers = [...new Set(resultsWithMatchingName.map(result => result.browserId))].sort(); + + return `No test result found with name "${args.name}" in browser "${args.browser}". Available browsers: ${availableBrowsers.join(", ")}.`; + } + + const availableAttempts = [...new Set(resultsWithMatchingBrowser.map(result => result.attempt))].sort(); + + return `No attempt ${args.attempt} found for test "${args.name}" in browser "${args.browser}". Available attempts: ${availableAttempts.join(", ")}.`; +} + +export function findReportTestResult( + results: readonly ReporterTestResult[], + args: ReportResultSelection, +): ReporterTestResult { + const matchingAttempts = results.filter( + result => result.fullName === args.name && result.browserId === args.browser, + ); + + if (!matchingAttempts.length) { + throw new Error(getSelectionError(results, args)); + } + + if (args.attempt === undefined) { + return getLatestAttempt(matchingAttempts); + } + + const matchingAttempt = matchingAttempts.find(result => result.attempt === args.attempt); + if (!matchingAttempt) { + throw new Error(getSelectionError(results, args)); + } + + return matchingAttempt; +} + +function isSnapshotAttachment(attachment: unknown): attachment is SnapshotAttachment { + return ( + typeof attachment === "object" && + attachment !== null && + "path" in attachment && + typeof attachment.path === "string" && + "type" in attachment && + (attachment.type === 0 || attachment.type === "snapshot") + ); +} + +export function getSnapshotAttachment(result: ReporterTestResult): SnapshotAttachment { + const attachment = result.attachments?.find(candidate => isSnapshotAttachment(candidate)); + if (!isSnapshotAttachment(attachment)) { + throw new Error( + `No time travel snapshot attachment found for "${result.fullName}" in browser "${result.browserId}" attempt ${result.attempt}.`, + ); + } + + return attachment; +} + +function isProbablyFileUrl(url: URL): boolean { + if (url.pathname.endsWith("/")) { + return false; + } + + return path.posix.basename(url.pathname).includes("."); +} + +function resolveRemoteSnapshotPath(report: string, attachmentPath: string): string { + if (isRemoteSource(attachmentPath)) { + return attachmentPath; + } + + const baseUrl = new URL(report); + if (!isProbablyFileUrl(baseUrl) && !baseUrl.pathname.endsWith("/")) { + baseUrl.pathname = `${baseUrl.pathname}/`; + } + + return new URL(attachmentPath, baseUrl).toString(); +} + +async function getLocalReportDir(reportPath: string): Promise { + try { + const reportStat = await stat(reportPath); + + return reportStat.isDirectory() ? reportPath : path.dirname(reportPath); + } catch { + return path.dirname(reportPath); + } +} + +export async function resolveSnapshotAttachmentSource( + originalReport: string, + resolvedReportPath: string, + attachmentPath: string, +): Promise { + if (isRemoteSource(originalReport)) { + return resolveRemoteSnapshotPath(originalReport, attachmentPath); + } + + if (isRemoteSource(attachmentPath)) { + return attachmentPath; + } + + const reportDir = await getLocalReportDir(resolvedReportPath); + + return path.resolve(reportDir, attachmentPath); +} + +function findFirstFailedStep(steps: readonly ReporterTestStep[] | undefined): ReporterTestStep | null { + for (const step of steps ?? []) { + if (step.f) { + return step; + } + + const childFailedStep = findFirstFailedStep(step.c); + if (childFailedStep) { + return childFailedStep; + } + } + + return null; +} + +export function getReportDefaultTime(result: ReporterTestResult): ReportDefaultTime | undefined { + const failedStep = findFirstFailedStep(result.history); + if (failedStep) { + return { + absoluteTime: failedStep.ts + failedStep.d, + reason: `default failed step "${failedStep.n}" end`, + }; + } + + if (result.status === "fail" || result.status === "error") { + return { + absoluteTime: result.timestamp + result.duration, + reason: `default ${result.status} result end`, + }; + } + + return undefined; +} diff --git a/packages/tools/src/tools/time-travel-snapshot/rrweb-snapshots.ts b/packages/tools/src/tools/time-travel-snapshot/rrweb-snapshots.ts new file mode 100644 index 0000000..fd3fff6 --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/rrweb-snapshots.ts @@ -0,0 +1,157 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { unzipSync } from "fflate"; +import type { eventWithTime as RrwebEvent } from "@rrweb/types"; +import { SelectedSnapshotTime } from "./types.js"; + +export type NumberedRrwebEvent = RrwebEvent & { seqNo: number }; + +export interface TimeTravelArchive { + source: string; + events: NumberedRrwebEvent[]; + metadata: RrwebSnapshotMetadata; +} + +export interface RrwebSnapshotMetadata { + startTime: number; + endTime: number; + totalTime: number; + width?: number; + height?: number; +} + +export interface ResolveTargetTimeOptions { + time?: number; + defaultAbsoluteTime?: number; + defaultReason?: string; +} + +const SNAPSHOTS_FILE_NAME = "snapshots.json"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isRemoteSource(source: string): boolean { + try { + const url = new URL(source); + + return Boolean(url.host && url.protocol !== "file:"); + } catch { + return false; + } +} + +function sourceToLocalPath(source: string): string { + try { + const url = new URL(source); + + if (url.protocol === "file:") { + return fileURLToPath(url); + } + } catch { + // Not a URL, treat it as a local path. + } + + return source; +} + +async function readTimeTravelZip(source: string): Promise { + if (isRemoteSource(source)) { + const response = await fetch(source); + if (!response.ok) { + throw new Error(`Failed to fetch snapshot archive "${source}": ${response.status} ${response.statusText}`); + } + + return new Uint8Array(await response.arrayBuffer()); + } + + return new Uint8Array(await readFile(sourceToLocalPath(source))); +} + +function getViewportMetadata(events: readonly NumberedRrwebEvent[]): Pick { + const metaEvent = events.find(event => event.type === 4 && isRecord(event.data)); + const data = isRecord(metaEvent?.data) ? metaEvent.data : undefined; + const width = typeof data?.width === "number" ? data.width : undefined; + const height = typeof data?.height === "number" ? data.height : undefined; + + return { width, height }; +} + +export async function loadTimeTravelArchive(source: string): Promise { + const zipData = await readTimeTravelZip(source); + const files = unzipSync(zipData); + const snapshotsFile = files[SNAPSHOTS_FILE_NAME]; + if (!snapshotsFile) { + throw new Error(`Couldn't find ${SNAPSHOTS_FILE_NAME} in "${source}".`); + } + + const jsonl = new TextDecoder("utf-8").decode(snapshotsFile); + const events = jsonl.split("\n").map(line => JSON.parse(line) as NumberedRrwebEvent); + + if (events.length < 2) { + throw new Error(`Snapshot archive "${source}" is empty (contains less than 2 events).`); + } + + const startTime = events[0].timestamp; + const endTime = events[events.length - 1].timestamp; + + return { + source, + events, + metadata: { + startTime, + endTime, + totalTime: endTime - startTime, + ...getViewportMetadata(events), + }, + }; +} + +function clampTime(time: number, metadata: RrwebSnapshotMetadata): number { + return Math.min(Math.max(time, metadata.startTime), metadata.endTime); +} + +export function resolveTargetTime( + metadata: RrwebSnapshotMetadata, + { time, defaultAbsoluteTime, defaultReason }: ResolveTargetTimeOptions, +): SelectedSnapshotTime { + let requestedKind: SelectedSnapshotTime["requestedKind"]; + let requestedTime: number | undefined; + let unclampedTime: number; + let reason: string; + + if (time !== undefined) { + requestedTime = time; + if (time <= metadata.totalTime) { + requestedKind = "offset"; + unclampedTime = metadata.startTime + time; + reason = `provided offset ${time}ms from first rrweb event`; + } else { + requestedKind = "timestamp"; + unclampedTime = time; + reason = `provided absolute timestamp ${time}`; + } + } else if (defaultAbsoluteTime !== undefined) { + requestedKind = "default"; + unclampedTime = defaultAbsoluteTime; + reason = defaultReason ?? "default time"; + } else { + requestedKind = "default"; + unclampedTime = metadata.endTime; + reason = "default snapshot end"; + } + + const absoluteTime = clampTime(unclampedTime, metadata); + const wasClamped = absoluteTime !== unclampedTime; + + return { + absoluteTime, + offsetMs: absoluteTime - metadata.startTime, + reason: wasClamped ? `${reason}; clamped to available snapshot range` : reason, + requestedTime, + requestedKind, + unclampedTime, + wasClamped, + }; +} diff --git a/packages/tools/src/tools/time-travel-snapshot/schema.ts b/packages/tools/src/tools/time-travel-snapshot/schema.ts new file mode 100644 index 0000000..cfa1e90 --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/schema.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +export const timeTravelSnapshotSchema = { + report: z + .string() + .min(1) + .transform(value => value.trim()) + .optional() + .describe("Path or URL to a Testplane HTML report directory, report HTML file, or databaseUrls.json"), + name: z + .string() + .min(1) + .transform(value => value.trim()) + .optional() + .describe("Full test name to inspect in report mode"), + browser: z + .string() + .min(1) + .transform(value => value.trim()) + .optional() + .describe("Browser id to inspect in report mode"), + attempt: z + .number() + .int() + .min(0, "--attempt must be >= 0") + .optional() + .describe("Attempt index to inspect. Defaults to the latest attempt for the selected test and browser"), + snapshotFile: z + .string() + .min(1) + .transform(value => value.trim()) + .optional() + .describe("Path or URL to a time travel snapshot zip. Mutually exclusive with report mode"), + time: z + .number() + .finite() + .min(0, "--time must be >= 0") + .optional() + .describe( + "Time to inspect in milliseconds. Values within snapshot duration are offsets from the first rrweb event; larger values are absolute timestamps", + ), + diffFrom: z + .number() + .finite() + .min(0, "--diff-from must be >= 0") + .optional() + .describe( + "When set, return only current DOM nodes that changed between this time and the requested time. Uses the same offset/timestamp rules as time", + ), + includeTags: z.array(z.string()).optional().describe("HTML tags to include in the snapshot besides defaults"), + includeAttrs: z + .array(z.string()) + .optional() + .describe("HTML attributes to include in the snapshot besides defaults"), + excludeTags: z.array(z.string()).optional().describe("HTML tags to exclude from the snapshot"), + excludeAttrs: z.array(z.string()).optional().describe("HTML attributes to exclude from the snapshot"), + truncateText: z.boolean().optional().describe("Whether to truncate long text content (default: true)"), + maxTextLength: z.number().positive().optional().describe("Maximum length of text content before truncation"), +}; + +export const timeTravelSnapshotObjectSchema = z.object(timeTravelSnapshotSchema).superRefine((args, ctx) => { + const hasReportMode = args.report !== undefined || args.name !== undefined || args.browser !== undefined; + const hasDirectMode = args.snapshotFile !== undefined; + + if (hasReportMode && hasDirectMode) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Use either report mode ("report", "name", "browser") or direct mode ("snapshotFile"), not both.', + }); + return; + } + + if (hasDirectMode) { + if (args.time === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["time"], + message: '"time" is required when using "snapshotFile".', + }); + } + return; + } + + if (!args.report || !args.name || !args.browser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Provide either "snapshotFile" with "time" or all report mode fields: "report", "name", and "browser".', + }); + } +}); + +export type TimeTravelSnapshotArgs = z.output; diff --git a/packages/tools/src/tools/time-travel-snapshot/snapshot-diff.ts b/packages/tools/src/tools/time-travel-snapshot/snapshot-diff.ts new file mode 100644 index 0000000..d89542e --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/snapshot-diff.ts @@ -0,0 +1,854 @@ +import { createHash } from "node:crypto"; +import type { PageSnapshotResult } from "../../responses/browser-helpers.js"; + +type AttrValue = string | true; + +interface ParsedSnapshotNode { + raw: string; + tag?: string; + id?: string; + classes: string[]; + attrs: Record; + text?: string; + children: ParsedSnapshotNode[]; + selfHash: string; + subtreeHash: string; + nodeCount: number; +} + +interface ParseLineResult { + raw: string; + tag?: string; + id?: string; + classes: string[]; + attrs: Record; + text?: string; +} + +interface TreeLine { + indent: number; + text: string; +} + +interface MatchedChild { + oldNode: ParsedSnapshotNode; + newNode: ParsedSnapshotNode; + oldIndex: number; + newIndex: number; +} + +interface FieldChanges { + tagChanged: boolean; + idChanged: boolean; + rawChanged: boolean; + removedClasses: string[]; + addedClasses: string[]; + removedAttrs: Array<[string, AttrValue]>; + addedAttrs: Array<[string, AttrValue]>; + changedAttrs: Array<[string, AttrValue, AttrValue]>; + textChanged: boolean; +} + +const MATCH_THRESHOLD = 85; +const LONG_VALUE_MAX_LENGTH = 96; +const HEADER_CLASS_LIMIT = 3; +const MAX_RENDERED_SUBTREE_NODES = 24; +const HEADER_ATTRS = ["role", "name", "aria-label"] as const; + +function splitSnapshotTreeLines(content: string): TreeLine[] { + return content + .split("\n") + .map(line => { + const indent = line.length - line.trimStart().length; + const trimmed = line.trimStart(); + + return trimmed.startsWith("- ") ? { indent, text: trimmed.slice(2) } : null; + }) + .filter((line): line is TreeLine => Boolean(line)); +} + +function stripTrailingNodeColon(value: string): string { + let quote: string | null = null; + let bracketDepth = 0; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + + if (quote) { + if (char === quote && value[index - 1] !== "\\") { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === "[") { + bracketDepth += 1; + continue; + } + + if (char === "]" && bracketDepth > 0) { + bracketDepth -= 1; + } + } + + return bracketDepth === 0 && value.endsWith(":") ? value.slice(0, -1) : value; +} + +function findTextStart(value: string): number { + let quote: string | null = null; + let bracketDepth = 0; + let lastTextStart = -1; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + + if (quote) { + if (char === quote && value[index - 1] !== "\\") { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + if (bracketDepth === 0 && index > 0 && /\s/.test(value[index - 1])) { + lastTextStart = index; + } + quote = char; + continue; + } + + if (char === "[") { + bracketDepth += 1; + continue; + } + + if (char === "]" && bracketDepth > 0) { + bracketDepth -= 1; + } + } + + if (lastTextStart === -1 || value[value.length - 1] !== '"') { + return -1; + } + + return lastTextStart; +} + +function unquote(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed[0] === '"' && trimmed[trimmed.length - 1] === '"') { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +function extractText(value: string): { rest: string; text?: string } { + const textStart = findTextStart(value); + if (textStart === -1) { + return { rest: value }; + } + + return { + rest: value.slice(0, textStart).trimEnd(), + text: unquote(value.slice(textStart)), + }; +} + +function findAttrStart(value: string): number { + let quote: string | null = null; + let attrStart = -1; + + for (let index = value.length - 1; index >= 0; index -= 1) { + const char = value[index]; + + if (quote) { + if (char === quote && value[index - 1] !== "\\") { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === "[") { + attrStart = index; + break; + } + } + + return attrStart; +} + +function tokenizeAttrs(value: string): string[] { + const tokens: string[] = []; + let quote: string | null = null; + let token = ""; + + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + + if (quote) { + token += char; + if (char === quote && value[index - 1] !== "\\") { + quote = null; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + token += char; + continue; + } + + if (/\s/.test(char)) { + if (token) { + tokens.push(token); + token = ""; + } + continue; + } + + token += char; + } + + if (token) { + tokens.push(token); + } + + return tokens; +} + +function parseAttrs(value: string): Record { + const attrs: Record = {}; + + for (const token of tokenizeAttrs(value)) { + const equalsIndex = token.indexOf("="); + if (equalsIndex === -1) { + attrs[token] = true; + continue; + } + + const name = token.slice(0, equalsIndex); + if (!name) { + continue; + } + + attrs[name] = unquote(token.slice(equalsIndex + 1)); + } + + return attrs; +} + +function extractAttrs(value: string): { rest: string; attrs: Record } { + const attrStart = findAttrStart(value); + if (attrStart === -1 || !value.endsWith("]")) { + return { rest: value, attrs: {} }; + } + + return { + rest: value.slice(0, attrStart).trimEnd(), + attrs: parseAttrs(value.slice(attrStart + 1, -1)), + }; +} + +function parseHeader(value: string): Pick { + const result: Pick = { classes: [] }; + const tagMatch = /^[^.#\s[\]"]+/.exec(value); + let index = 0; + + if (tagMatch) { + result.tag = tagMatch[0]; + index = tagMatch[0].length; + } + + while (index < value.length) { + const marker = value[index]; + if (marker !== "." && marker !== "#") { + break; + } + + const start = index + 1; + index = start; + while (index < value.length && value[index] !== "." && value[index] !== "#") { + index += 1; + } + + const token = value.slice(start, index); + if (!token) { + continue; + } + + if (marker === "#") { + result.id = token; + } else { + result.classes.push(token); + } + } + + return result; +} + +function parseSnapshotLine(rawLine: string): ParseLineResult { + try { + const raw = stripTrailingNodeColon(rawLine.trim()); + const withText = extractText(raw); + const withAttrs = extractAttrs(withText.rest); + const header = parseHeader(withAttrs.rest); + + return { + raw, + ...header, + attrs: withAttrs.attrs, + text: withText.text, + }; + } catch { + return { + raw: rawLine.trim(), + classes: [], + attrs: {}, + }; + } +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + if (!value || typeof value !== "object") { + return JSON.stringify(value); + } + + return `{${Object.keys(value) + .sort() + .map(key => `${JSON.stringify(key)}:${stableStringify((value as Record)[key])}`) + .join(",")}}`; +} + +function hashString(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function getSelfHash(node: ParseLineResult): string { + return hashString( + stableStringify({ + raw: node.tag ? undefined : node.raw, + tag: node.tag, + id: node.id, + classes: [...node.classes].sort(), + attrs: node.attrs, + text: node.text, + }), + ); +} + +function recursiveRecomputeState(node: ParsedSnapshotNode): void { + for (const child of node.children) { + recursiveRecomputeState(child); + } + + node.nodeCount = 1 + node.children.reduce((sum, child) => sum + child.nodeCount, 0); + node.subtreeHash = hashString(`${node.selfHash}|${node.children.map(child => child.subtreeHash).join("|")}`); +} + +function parseSnapshot(content: string): ParsedSnapshotNode[] { + const root: ParsedSnapshotNode = { + raw: "", + classes: [], + attrs: {}, + children: [], + selfHash: "", + subtreeHash: "", + nodeCount: 0, + }; + const stack: Array<{ indent: number; node: ParsedSnapshotNode }> = [{ indent: -1, node: root }]; + + for (const line of splitSnapshotTreeLines(content)) { + const parsedLine = parseSnapshotLine(line.text); + const node: ParsedSnapshotNode = { + ...parsedLine, + children: [], + selfHash: getSelfHash(parsedLine), + subtreeHash: "", + nodeCount: 1, + }; + + while (stack[stack.length - 1].indent >= line.indent) { + stack.pop(); + } + + stack[stack.length - 1].node.children.push(node); + stack.push({ indent: line.indent, node }); + } + + recursiveRecomputeState(root); + + return root.children; +} + +function attrValueToString(value: AttrValue | undefined): string { + return value === true ? "true" : String(value ?? ""); +} + +function getImportantAttrScore(oldNode: ParsedSnapshotNode, newNode: ParsedSnapshotNode, attrName: string): number { + const oldValue = oldNode.attrs[attrName]; + const newValue = newNode.attrs[attrName]; + + return oldValue !== undefined && newValue !== undefined && oldValue === newValue ? 1 : 0; +} + +function getClassSimilarity(oldNode: ParsedSnapshotNode, newNode: ParsedSnapshotNode): number { + if (!oldNode.classes.length && !newNode.classes.length) { + return 0; + } + + const oldClasses = new Set(oldNode.classes); + const newClasses = new Set(newNode.classes); + const intersection = [...oldClasses].filter(className => newClasses.has(className)).length; + const union = new Set([...oldClasses, ...newClasses]).size; + + return union === 0 ? 0 : intersection / union; +} + +function scoreNodePair( + oldNode: ParsedSnapshotNode, + newNode: ParsedSnapshotNode, + oldIndex: number, + newIndex: number, +): number { + if (oldNode.subtreeHash === newNode.subtreeHash) { + return 10_000 - Math.abs(oldIndex - newIndex); + } + + let score = 0; + + if (oldNode.selfHash === newNode.selfHash) { + score += 500; + } + if (oldNode.raw === newNode.raw) { + score += 200; + } + if (oldNode.tag && oldNode.tag === newNode.tag) { + score += 45; + } else if (oldNode.tag || newNode.tag) { + score -= 40; + } + if (oldNode.id && newNode.id && oldNode.id === newNode.id) { + score += 160; + } + score += getImportantAttrScore(oldNode, newNode, "role") * 90; + score += getImportantAttrScore(oldNode, newNode, "name") * 90; + score += getImportantAttrScore(oldNode, newNode, "aria-label") * 90; + if (oldNode.text && newNode.text && oldNode.text === newNode.text) { + score += 80; + } + if ( + oldIndex === newIndex && + oldNode.tag && + oldNode.tag === newNode.tag && + oldNode.children.length === newNode.children.length + ) { + score += 25; + } + score += getClassSimilarity(oldNode, newNode) * 80; + score += Math.max(0, 24 - Math.abs(oldIndex - newIndex) * 4); + + return score; +} + +function matchChildren( + oldChildren: readonly ParsedSnapshotNode[], + newChildren: readonly ParsedSnapshotNode[], +): MatchedChild[] { + const pairs: Array = []; + + for (let oldIndex = 0; oldIndex < oldChildren.length; oldIndex += 1) { + for (let newIndex = 0; newIndex < newChildren.length; newIndex += 1) { + const score = scoreNodePair(oldChildren[oldIndex], newChildren[newIndex], oldIndex, newIndex); + if (score >= MATCH_THRESHOLD) { + pairs.push({ + oldNode: oldChildren[oldIndex], + newNode: newChildren[newIndex], + oldIndex, + newIndex, + score, + }); + } + } + } + + pairs.sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + if (left.newIndex !== right.newIndex) { + return left.newIndex - right.newIndex; + } + + return left.oldIndex - right.oldIndex; + }); + + const usedOldIndexes = new Set(); + const usedNewIndexes = new Set(); + const matches: MatchedChild[] = []; + + for (const pair of pairs) { + if (usedOldIndexes.has(pair.oldIndex) || usedNewIndexes.has(pair.newIndex)) { + continue; + } + + usedOldIndexes.add(pair.oldIndex); + usedNewIndexes.add(pair.newIndex); + matches.push(pair); + } + + return matches.sort((left, right) => left.newIndex - right.newIndex || left.oldIndex - right.oldIndex); +} + +function truncateValue(value: string): string { + return value.length > LONG_VALUE_MAX_LENGTH ? `${value.slice(0, LONG_VALUE_MAX_LENGTH - 3)}...` : value; +} + +function quoteValue(value: string): string { + return JSON.stringify(truncateValue(value)); +} + +function renderAttr(name: string, value: AttrValue): string { + return value === true ? name : `${name}=${quoteValue(value)}`; +} + +function renderNodeHeader(node: ParsedSnapshotNode): string { + if (!node.tag) { + return truncateValue(node.raw); + } + + const classSuffix = node.classes + .slice(0, HEADER_CLASS_LIMIT) + .map(className => `.${truncateValue(className)}`) + .join(""); + const omittedClassSuffix = node.classes.length > HEADER_CLASS_LIMIT ? ".(...)" : ""; + const idSuffix = node.id ? `#${truncateValue(node.id)}` : ""; + const attrs = HEADER_ATTRS.filter(attrName => node.attrs[attrName] !== undefined) + .map(attrName => renderAttr(attrName, node.attrs[attrName])) + .join(" "); + const attrsSuffix = attrs ? `[${attrs}]` : ""; + const textSuffix = node.text === undefined ? "" : ` ${quoteValue(node.text)}`; + + return `${node.tag}${classSuffix}${omittedClassSuffix}${idSuffix}${attrsSuffix}${textSuffix}`; +} + +function diffFields(oldNode: ParsedSnapshotNode, newNode: ParsedSnapshotNode): FieldChanges { + const oldClasses = new Set(oldNode.classes); + const newClasses = new Set(newNode.classes); + const oldAttrNames = Object.keys(oldNode.attrs).sort(); + const newAttrNames = Object.keys(newNode.attrs).sort(); + const oldAttrNameSet = new Set(oldAttrNames); + const newAttrNameSet = new Set(newAttrNames); + const sharedAttrNames = oldAttrNames.filter(attrName => newAttrNameSet.has(attrName)); + + return { + tagChanged: oldNode.tag !== newNode.tag, + idChanged: oldNode.id !== newNode.id, + rawChanged: !oldNode.tag && !newNode.tag && oldNode.raw !== newNode.raw, + removedClasses: [...oldClasses].filter(className => !newClasses.has(className)).sort(), + addedClasses: [...newClasses].filter(className => !oldClasses.has(className)).sort(), + removedAttrs: oldAttrNames + .filter(attrName => !newAttrNameSet.has(attrName)) + .map(attrName => [attrName, oldNode.attrs[attrName]]), + addedAttrs: newAttrNames + .filter(attrName => !oldAttrNameSet.has(attrName)) + .map(attrName => [attrName, newNode.attrs[attrName]]), + changedAttrs: sharedAttrNames + .filter(attrName => oldNode.attrs[attrName] !== newNode.attrs[attrName]) + .map(attrName => [attrName, oldNode.attrs[attrName], newNode.attrs[attrName]]), + textChanged: oldNode.text !== newNode.text, + }; +} + +function hasFieldChanges(changes: FieldChanges): boolean { + return ( + changes.tagChanged || + changes.idChanged || + changes.rawChanged || + changes.removedClasses.length > 0 || + changes.addedClasses.length > 0 || + changes.removedAttrs.length > 0 || + changes.addedAttrs.length > 0 || + changes.changedAttrs.length > 0 || + changes.textChanged + ); +} + +function renderFieldChanges(changes: FieldChanges, oldNode: ParsedSnapshotNode, newNode: ParsedSnapshotNode): string[] { + const lines: string[] = []; + + if (changes.tagChanged) { + lines.push(` tag: ${oldNode.tag ?? "(none)"} -> ${newNode.tag ?? "(none)"}`); + } + if (changes.idChanged) { + lines.push(` id: ${quoteValue(oldNode.id ?? "")} -> ${quoteValue(newNode.id ?? "")}`); + } + if (changes.rawChanged) { + lines.push(` raw: ${quoteValue(oldNode.raw)} -> ${quoteValue(newNode.raw)}`); + } + if (changes.textChanged) { + lines.push(` text: ${quoteValue(oldNode.text ?? "")} -> ${quoteValue(newNode.text ?? "")}`); + } + + if (changes.removedClasses.length > 0 || changes.addedClasses.length > 0) { + lines.push(" classes:"); + for (const className of changes.removedClasses) { + lines.push(` - ${truncateValue(className)}`); + } + for (const className of changes.addedClasses) { + lines.push(` + ${truncateValue(className)}`); + } + } + + if (changes.removedAttrs.length > 0 || changes.addedAttrs.length > 0 || changes.changedAttrs.length > 0) { + lines.push(" attrs:"); + for (const [name, value] of changes.removedAttrs) { + lines.push(` - ${renderAttr(name, value)}`); + } + for (const [name, value] of changes.addedAttrs) { + lines.push(` + ${renderAttr(name, value)}`); + } + for (const [name, oldValue, newValue] of changes.changedAttrs) { + lines.push( + ` ~ ${name}: ${quoteValue(attrValueToString(oldValue))} -> ${quoteValue(attrValueToString(newValue))}`, + ); + } + } + + return lines; +} + +function countOmittedNodes(node: ParsedSnapshotNode, changedChildren: readonly DiffEntry[]): number { + const changedNewIndexes = new Set( + changedChildren + .filter(entry => entry.kind !== "removed") + .map(entry => entry.newIndex) + .filter(index => index !== undefined), + ); + let omitted = 0; + + for (let index = 0; index < node.children.length; index += 1) { + if (changedNewIndexes.has(index)) { + continue; + } + + omitted += node.children[index].nodeCount; + } + + return omitted; +} + +type DiffEntry = + | { + kind: "changed"; + node: ParsedSnapshotNode; + oldNode: ParsedSnapshotNode; + children: DiffEntry[]; + oldIndex: number; + newIndex: number; + } + | { kind: "added"; node: ParsedSnapshotNode; newIndex: number } + | { kind: "removed"; node: ParsedSnapshotNode; oldIndex: number }; + +function diffNode( + oldNode: ParsedSnapshotNode, + newNode: ParsedSnapshotNode, + oldIndex: number, + newIndex: number, +): DiffEntry | null { + if (oldNode.subtreeHash === newNode.subtreeHash) { + return null; + } + + const matches = matchChildren(oldNode.children, newNode.children); + const matchedOldIndexes = new Set(matches.map(match => match.oldIndex)); + const matchedNewIndexes = new Set(matches.map(match => match.newIndex)); + const childEntries: DiffEntry[] = []; + + for (const match of matches) { + const childEntry = diffNode(match.oldNode, match.newNode, match.oldIndex, match.newIndex); + if (childEntry) { + childEntries.push(childEntry); + } + } + + for (let index = 0; index < oldNode.children.length; index += 1) { + if (!matchedOldIndexes.has(index)) { + childEntries.push({ kind: "removed", node: oldNode.children[index], oldIndex: index }); + } + } + + for (let index = 0; index < newNode.children.length; index += 1) { + if (!matchedNewIndexes.has(index)) { + childEntries.push({ kind: "added", node: newNode.children[index], newIndex: index }); + } + } + + childEntries.sort((left, right) => { + const leftIndex = left.kind === "removed" ? left.oldIndex : left.newIndex; + const rightIndex = right.kind === "removed" ? right.oldIndex : right.newIndex; + + return leftIndex - rightIndex; + }); + + return { + kind: "changed", + node: newNode, + oldNode, + children: childEntries, + oldIndex, + newIndex, + }; +} + +function diffTrees(oldNodes: readonly ParsedSnapshotNode[], newNodes: readonly ParsedSnapshotNode[]): DiffEntry[] { + const matches = matchChildren(oldNodes, newNodes); + const matchedOldIndexes = new Set(matches.map(match => match.oldIndex)); + const matchedNewIndexes = new Set(matches.map(match => match.newIndex)); + const entries: DiffEntry[] = []; + + for (const match of matches) { + const entry = diffNode(match.oldNode, match.newNode, match.oldIndex, match.newIndex); + if (entry) { + entries.push(entry); + } + } + + for (let index = 0; index < oldNodes.length; index += 1) { + if (!matchedOldIndexes.has(index)) { + entries.push({ kind: "removed", node: oldNodes[index], oldIndex: index }); + } + } + + for (let index = 0; index < newNodes.length; index += 1) { + if (!matchedNewIndexes.has(index)) { + entries.push({ kind: "added", node: newNodes[index], newIndex: index }); + } + } + + return entries.sort((left, right) => { + const leftIndex = left.kind === "removed" ? left.oldIndex : left.newIndex; + const rightIndex = right.kind === "removed" ? right.oldIndex : right.newIndex; + + return leftIndex - rightIndex; + }); +} + +function indentLines(lines: readonly string[], depth: number): string[] { + const indent = " ".repeat(depth); + + return lines.map(line => (line ? `${indent}${line}` : line)); +} + +function renderSubtree( + node: ParsedSnapshotNode, + prefix: "+" | "-", + depth: number, + budget: { remaining: number } = { remaining: MAX_RENDERED_SUBTREE_NODES }, +): string[] { + if (budget.remaining <= 0) { + return [`${" ".repeat(depth)}${prefix} ... subtree omitted: ${node.nodeCount} nodes`]; + } + + budget.remaining -= 1; + + const lines = [`${" ".repeat(depth)}${prefix} ${renderNodeHeader(node)}`]; + + for (const child of node.children) { + if (budget.remaining <= 0) { + const omittedNodes = node.children + .slice(node.children.indexOf(child)) + .reduce((sum, omittedChild) => sum + omittedChild.nodeCount, 0); + + lines.push(`${" ".repeat(depth + 1)}${prefix} ... subtree omitted: ${omittedNodes} nodes`); + break; + } + + lines.push(...renderSubtree(child, prefix, depth + 1, budget)); + } + + return lines; +} + +function renderChangedEntry(entry: Extract, depth: number): string[] { + const lines = [`${" ".repeat(depth)}~ ${renderNodeHeader(entry.node)}`]; + const changes = diffFields(entry.oldNode, entry.node); + const fieldLines = renderFieldChanges(changes, entry.oldNode, entry.node); + const omittedNodes = countOmittedNodes(entry.node, entry.children); + + if (fieldLines.length > 0) { + lines.push(...indentLines(fieldLines, depth)); + } + + if (entry.children.length === 0) { + const omittedChildren = entry.node.children.reduce((sum, child) => sum + child.nodeCount, 0); + if (omittedChildren > 0) { + lines.push(`${" ".repeat(depth + 1)}children unchanged: ${omittedChildren} nodes omitted`); + } + + return lines; + } + + if (hasFieldChanges(changes) || omittedNodes > 0) { + lines.push(`${" ".repeat(depth + 1)}...`); + } + + lines.push(...entry.children.flatMap(child => renderEntry(child, depth + 1))); + + return lines; +} + +function renderEntry(entry: DiffEntry, depth: number): string[] { + if (entry.kind === "added") { + return renderSubtree(entry.node, "+", depth); + } + + if (entry.kind === "removed") { + return renderSubtree(entry.node, "-", depth); + } + + return renderChangedEntry(entry, depth); +} + +export function diffPageSnapshots(baseline: PageSnapshotResult, target: PageSnapshotResult): PageSnapshotResult { + const fallbackResult = { + fenceLanguage: "yaml", + content: "# Failed to diff snapshots, below is new snapshot as a fallback.\n" + target.content, + } as const; + if (baseline.fenceLanguage !== target.fenceLanguage || target.fenceLanguage !== "yaml") { + return fallbackResult; + } + + try { + const baselineTree = parseSnapshot(baseline.content); + const targetTree = parseSnapshot(target.content); + const entries = diffTrees(baselineTree, targetTree); + + const lines = + entries.length > 0 + ? entries.flatMap(entry => renderEntry(entry, 0)) + : ["# No DOM nodes changed between the selected times."]; + + return { + fenceLanguage: "diff", + content: lines.join("\n"), + }; + } catch { + return fallbackResult; + } +} diff --git a/packages/tools/src/tools/time-travel-snapshot/types.ts b/packages/tools/src/tools/time-travel-snapshot/types.ts new file mode 100644 index 0000000..12c4f7f --- /dev/null +++ b/packages/tools/src/tools/time-travel-snapshot/types.ts @@ -0,0 +1,23 @@ +import { ReporterTestResult } from "html-reporter/experimental/sdk"; + +export interface SnapshotInputSelection { + mode: "report" | "direct"; + source: string; + result?: ReporterTestResult; + defaultTime?: { + absoluteTime: number; + reason: string; + }; +} + +export type ReporterTestStep = NonNullable[number]; + +export interface SelectedSnapshotTime { + absoluteTime: number; + offsetMs: number; + reason: string; + requestedTime?: number; + requestedKind?: "offset" | "timestamp" | "default"; + unclampedTime?: number; + wasClamped: boolean; +} diff --git a/packages/tools/test/responses/index.test.ts b/packages/tools/test/responses/index.test.ts index f48addf..6f683c8 100644 --- a/packages/tools/test/responses/index.test.ts +++ b/packages/tools/test/responses/index.test.ts @@ -9,9 +9,11 @@ import { import * as browserHelpers from "../../src/responses/browser-helpers.js"; vi.mock("../../src/responses/browser-helpers.js", () => ({ + INLINE_SNAPSHOT_MAX_LENGTH: 32_000, + convertSnapshotToResponse: vi.fn(), getBrowserTabs: vi.fn(), getPageSnapshot: vi.fn(), - savePageSnapshotToFile: vi.fn(), + isPageSnapshotTooLargeForInline: vi.fn((snapshot: { content: string }) => snapshot.content.length > 32_000), })); describe("responses/index", () => { @@ -60,21 +62,23 @@ describe("responses/index", () => { describe("createBrowserStateResponse", () => { let mockBrowser: WdioBrowser; + let mockConvertSnapshotToResponse: ReturnType; let mockGetBrowserTabs: ReturnType; let mockGetPageSnapshot: ReturnType; - let mockSavePageSnapshotToFile: ReturnType; beforeEach(() => { mockBrowser = {} as WdioBrowser; + mockConvertSnapshotToResponse = vi.mocked(browserHelpers.convertSnapshotToResponse); mockGetBrowserTabs = vi.mocked(browserHelpers.getBrowserTabs); mockGetPageSnapshot = vi.mocked(browserHelpers.getPageSnapshot); - mockSavePageSnapshotToFile = vi.mocked(browserHelpers.savePageSnapshotToFile); + mockGetBrowserTabs.mockResolvedValue([]); + mockGetPageSnapshot.mockResolvedValue(null); + mockConvertSnapshotToResponse.mockResolvedValue( + "The snapshot was saved to: /tmp/.testplane/snapshots/snap.yml", + ); }); it("should create response with action only", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue(null); - const options: BrowserResponseOptions = { action: "Page loaded successfully", }; @@ -86,9 +90,6 @@ describe("responses/index", () => { }); it("should include testplane code when provided", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue(null); - const options: BrowserResponseOptions = { action: "Click performed", testplaneCode: 'await browser.click("#submit-button");', @@ -110,7 +111,6 @@ describe("responses/index", () => { { title: "GitHub", url: "https://github.com", isActive: false }, ]; mockGetBrowserTabs.mockResolvedValue(mockTabs); - mockSavePageSnapshotToFile.mockResolvedValue(null); const options: BrowserResponseOptions = { action: "Navigation completed", @@ -125,10 +125,14 @@ describe("responses/index", () => { }); it("should save snapshot to file by default and include path in response", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue({ - filePath: "/tmp/.testplane/snapshots/2026-05-04T12-34-56-789Z.yml", - }); + const snapshot = { + content: "Snapshot content", + fenceLanguage: "yaml" as const, + }; + mockGetPageSnapshot.mockResolvedValue(snapshot); + mockConvertSnapshotToResponse.mockResolvedValue( + "The snapshot was saved to: /tmp/.testplane/snapshots/2026-05-04T12-34-56-789Z.yml", + ); const options: BrowserResponseOptions = { action: "Snapshot saved", @@ -137,20 +141,38 @@ describe("responses/index", () => { const result = await createBrowserStateResponse(mockBrowser, options); const responseText = result.content[0].text; - expect(mockSavePageSnapshotToFile).toHaveBeenCalledOnce(); - expect(mockGetPageSnapshot).not.toHaveBeenCalled(); + expect(mockGetPageSnapshot).toHaveBeenCalledOnce(); + expect(mockConvertSnapshotToResponse).toHaveBeenCalledWith(snapshot, { forceSaveToFile: true }); expect(responseText).toContain("## Current Tab Snapshot"); - expect(responseText).toContain("Saved to: /tmp/.testplane/snapshots/2026-05-04T12-34-56-789Z.yml"); - expect(responseText).toContain("some tags/attributes may be omitted"); + expect(responseText).toContain( + "The snapshot was saved to: /tmp/.testplane/snapshots/2026-05-04T12-34-56-789Z.yml", + ); expect(responseText).not.toContain("```yaml"); }); + it("should show when no snapshot was captured", async () => { + mockGetPageSnapshot.mockResolvedValue(null); + + const options: BrowserResponseOptions = { + action: "Snapshot saved", + }; + + const result = await createBrowserStateResponse(mockBrowser, options); + const responseText = result.content[0].text; + + expect(mockGetPageSnapshot).toHaveBeenCalledOnce(); + expect(mockConvertSnapshotToResponse).not.toHaveBeenCalled(); + expect(responseText).toContain("## Current Tab Snapshot"); + expect(responseText).toContain("No snapshot captured"); + }); + it("should inline snapshot when inlineSnapshot is true", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockGetPageSnapshot.mockResolvedValue({ + const snapshot = { content: "Test content", - fenceLanguage: "yaml", - }); + fenceLanguage: "yaml" as const, + }; + mockGetPageSnapshot.mockResolvedValue(snapshot); + mockConvertSnapshotToResponse.mockResolvedValue("```yaml\nTest content\n```"); const options: BrowserResponseOptions = { action: "Snapshot captured", @@ -161,15 +183,39 @@ describe("responses/index", () => { const responseText = result.content[0].text; expect(mockGetPageSnapshot).toHaveBeenCalledOnce(); - expect(mockSavePageSnapshotToFile).not.toHaveBeenCalled(); + expect(mockConvertSnapshotToResponse).toHaveBeenCalledWith(snapshot, { forceSaveToFile: false }); expect(responseText).toContain("## Current Tab Snapshot"); expect(responseText).toContain("Test content"); - expect(responseText).not.toContain("Saved to:"); + expect(responseText).not.toContain("The snapshot was saved to:"); }); - it("should skip snapshot when isSnapshotNeeded is false", async () => { - mockGetBrowserTabs.mockResolvedValue([]); + it("should delegate large inline snapshot handling to snapshot response formatter", async () => { + const snapshot = { + content: "x".repeat(32_001), + fenceLanguage: "yaml" as const, + }; + mockGetPageSnapshot.mockResolvedValue(snapshot); + mockConvertSnapshotToResponse.mockResolvedValue( + "The snapshot is too large to include inline (32001 characters; limit 32000), so it was saved to: /tmp/.testplane/snapshots/large.yml", + ); + + const options: BrowserResponseOptions = { + action: "Snapshot captured", + inlineSnapshot: true, + }; + + const result = await createBrowserStateResponse(mockBrowser, options); + const responseText = result.content[0].text; + + expect(mockGetPageSnapshot).toHaveBeenCalledOnce(); + expect(mockConvertSnapshotToResponse).toHaveBeenCalledWith(snapshot, { forceSaveToFile: false }); + expect(responseText).toContain("## Current Tab Snapshot"); + expect(responseText).toContain("The snapshot is too large to include inline"); + expect(responseText).toContain("so it was saved to: /tmp/.testplane/snapshots/large.yml"); + expect(responseText).not.toContain("```yaml"); + }); + it("should skip snapshot when isSnapshotNeeded is false", async () => { const options: BrowserResponseOptions = { action: "No snapshot wanted", isSnapshotNeeded: false, @@ -178,15 +224,12 @@ describe("responses/index", () => { const result = await createBrowserStateResponse(mockBrowser, options); const responseText = result.content[0].text; - expect(mockSavePageSnapshotToFile).not.toHaveBeenCalled(); expect(mockGetPageSnapshot).not.toHaveBeenCalled(); + expect(mockConvertSnapshotToResponse).not.toHaveBeenCalled(); expect(responseText).not.toContain("## Current Tab Snapshot"); }); it("should include additional information when provided", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue(null); - const options: BrowserResponseOptions = { action: "Complex operation", additionalInfo: "This operation involved multiple steps and validations.", @@ -200,11 +243,16 @@ describe("responses/index", () => { }); it("should include all sections when all options are provided", async () => { + const snapshot = { + content: "Snapshot content", + fenceLanguage: "yaml" as const, + }; const mockTabs = [{ title: "Test Page", url: "https://test.com", isActive: true }]; mockGetBrowserTabs.mockResolvedValue(mockTabs); - mockSavePageSnapshotToFile.mockResolvedValue({ - filePath: "/tmp/.testplane/snapshots/snap.yml", - }); + mockGetPageSnapshot.mockResolvedValue(snapshot); + mockConvertSnapshotToResponse.mockResolvedValue( + "The snapshot was saved to: /tmp/.testplane/snapshots/snap.yml", + ); const options: BrowserResponseOptions = { action: "Complete operation", @@ -221,17 +269,15 @@ describe("responses/index", () => { expect(responseText).toContain("## Browser Tabs"); expect(responseText).toContain("1. Title: Test Page; URL: https://test.com (current)"); expect(responseText).toContain("## Current Tab Snapshot"); - expect(responseText).toContain("Saved to: /tmp/.testplane/snapshots/snap.yml"); + expect(responseText).toContain("The snapshot was saved to: /tmp/.testplane/snapshots/snap.yml"); expect(responseText).toContain("## Additional Information"); expect(responseText).toContain("All features tested successfully."); }); it("should handle empty tabs array", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue(null); - const options: BrowserResponseOptions = { action: "No tabs test", + isSnapshotNeeded: false, }; const result = await createBrowserStateResponse(mockBrowser, options); @@ -241,17 +287,16 @@ describe("responses/index", () => { expect(responseText).toContain("No opened tabs"); }); - it("should handle null snapshot save result", async () => { - mockGetBrowserTabs.mockResolvedValue([]); - mockSavePageSnapshotToFile.mockResolvedValue(null); - + it("should not include snapshot section when snapshot is disabled", async () => { const options: BrowserResponseOptions = { action: "No snapshot test", + isSnapshotNeeded: false, }; const result = await createBrowserStateResponse(mockBrowser, options); const responseText = result.content[0].text; + expect(mockGetPageSnapshot).not.toHaveBeenCalled(); expect(responseText).not.toContain("## Current Tab Snapshot"); expect(responseText).toContain("✅ No snapshot test"); }); diff --git a/packages/tools/test/setup.ts b/packages/tools/test/setup.ts index 0ab8e4f..d94e008 100644 --- a/packages/tools/test/setup.ts +++ b/packages/tools/test/setup.ts @@ -18,7 +18,7 @@ export function getTextContent(result: { content: unknown }): string { return content.map(item => item.text).join("\n"); } -const SAVED_SNAPSHOT_PATH_RE = /Saved to: (\S+\.(?:yml|html))/; +const SAVED_SNAPSHOT_PATH_RE = /(?:Saved to:|The snapshot was saved to:) (\S+\.(?:yml|html|diff))/; export function extractSnapshotPath(responseText: string): string { const match = responseText.match(SAVED_SNAPSHOT_PATH_RE); diff --git a/packages/tools/test/tools/click-on-element.test.ts b/packages/tools/test/tools/click-on-element.test.ts index 446256f..e100bde 100644 --- a/packages/tools/test/tools/click-on-element.test.ts +++ b/packages/tools/test/tools/click-on-element.test.ts @@ -42,7 +42,7 @@ describe( const text = getTextContent(result); expect(text).toContain("Successfully clicked element"); expect(text).toContain("## Current Tab Snapshot"); - expect(text).toMatch(/Saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); + expect(text).toMatch(/The snapshot was saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); }); it("should click an element using CSS selector", async () => { @@ -57,7 +57,7 @@ describe( const text = getTextContent(result); expect(text).toContain("Successfully clicked element"); expect(text).toContain("## Current Tab Snapshot"); - expect(text).toMatch(/Saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); + expect(text).toMatch(/The snapshot was saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); }); it("should return correct testplane code for clicked element", async () => { diff --git a/packages/tools/test/tools/hover-element.test.ts b/packages/tools/test/tools/hover-element.test.ts index 6b58bc6..91db384 100644 --- a/packages/tools/test/tools/hover-element.test.ts +++ b/packages/tools/test/tools/hover-element.test.ts @@ -42,7 +42,7 @@ describe( const text = getTextContent(result); expect(text).toContain("Successfully hovered element"); expect(text).toContain("## Current Tab Snapshot"); - expect(text).toMatch(/Saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); + expect(text).toMatch(/The snapshot was saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); }); it("should hover an element using CSS selector", async () => { @@ -57,7 +57,7 @@ describe( const text = getTextContent(result); expect(text).toContain("Successfully hovered element"); expect(text).toContain("## Current Tab Snapshot"); - expect(text).toMatch(/Saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); + expect(text).toMatch(/The snapshot was saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); }); it("should return correct testplane code for hovered element", async () => { diff --git a/packages/tools/test/tools/navigate.test.ts b/packages/tools/test/tools/navigate.test.ts index 5f5431d..45954f6 100644 --- a/packages/tools/test/tools/navigate.test.ts +++ b/packages/tools/test/tools/navigate.test.ts @@ -44,7 +44,7 @@ describe( const text = getTextContent(result); expect(text).toContain("## Browser Tabs"); expect(text).toContain("## Current Tab Snapshot"); - expect(text).toMatch(/Saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); + expect(text).toMatch(/The snapshot was saved to: .+\.testplane\/snapshots\/.+\.(yml|html)/); }); describe("timeout behavior", () => { diff --git a/packages/tools/test/tools/time-travel-snapshot.test.ts b/packages/tools/test/tools/time-travel-snapshot.test.ts new file mode 100644 index 0000000..075e5ff --- /dev/null +++ b/packages/tools/test/tools/time-travel-snapshot.test.ts @@ -0,0 +1,355 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { readResultsFromReport } from "html-reporter/experimental/sdk"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + findReportTestResult, + diffPageSnapshots, + getReportDefaultTime, + getSnapshotAttachment, + loadRrwebSnapshotArchive, + resolveSnapshotAttachmentSource, + resolveTargetTime, + timeTravelSnapshot, + timeTravelSnapshotObjectSchema, +} from "../../src/tools/time-travel-snapshot/index.js"; +import { formatReportTestSteps } from "../../src/tools/time-travel-snapshot/formatters.js"; +import { getTextContent } from "../setup.js"; + +const SAMPLE_REPORT = fileURLToPath(new URL("../fixtures/sample-html-report", import.meta.url)); +const SAMPLE_SNAPSHOT = path.join(SAMPLE_REPORT, "snapshots/2570334/chrome_1778522878896_1.zip"); + +type TimeTravelSnapshotInput = z.input; + +function parseArgs(args: TimeTravelSnapshotInput) { + return timeTravelSnapshotObjectSchema.parse(args); +} + +describe("tools/time-travel-snapshot", () => { + it("validates report mode and direct mode", () => { + expect( + timeTravelSnapshotObjectSchema.safeParse({ + report: SAMPLE_REPORT, + name: "success describe successfully passed test", + browser: "chrome", + }).success, + ).toBe(true); + + expect(timeTravelSnapshotObjectSchema.safeParse({ snapshotFile: SAMPLE_SNAPSHOT, time: 134 }).success).toBe( + true, + ); + expect( + timeTravelSnapshotObjectSchema.safeParse({ snapshotFile: SAMPLE_SNAPSHOT, time: 134, diffFrom: 10 }) + .success, + ).toBe(true); + + expect( + timeTravelSnapshotObjectSchema.safeParse({ + report: SAMPLE_REPORT, + name: "success describe successfully passed test", + browser: "chrome", + snapshotFile: SAMPLE_SNAPSHOT, + time: 134, + }).success, + ).toBe(false); + + expect(timeTravelSnapshotObjectSchema.safeParse({ snapshotFile: SAMPLE_SNAPSHOT }).success).toBe(false); + expect( + timeTravelSnapshotObjectSchema.safeParse({ snapshotFile: SAMPLE_SNAPSHOT, time: 134, diffFrom: -1 }) + .success, + ).toBe(false); + expect(timeTravelSnapshotObjectSchema.safeParse({ report: SAMPLE_REPORT, name: "test" }).success).toBe(false); + }); + + it("loads rrweb snapshot archives and resolves smart time values", async () => { + const archive = await loadRrwebSnapshotArchive(SAMPLE_SNAPSHOT); + + expect(archive.events).toHaveLength(5); + expect(archive.metadata).toMatchObject({ + startTime: 1778522878903, + endTime: 1778522879143, + totalTime: 240, + width: 1280, + height: 1024, + }); + + const offsetTime = resolveTargetTime(archive.metadata, { time: 134 }); + expect(offsetTime).toMatchObject({ + absoluteTime: 1778522879037, + offsetMs: 134, + requestedKind: "offset", + wasClamped: false, + }); + + const timestampTime = resolveTargetTime(archive.metadata, { time: 1778522879143 }); + expect(timestampTime).toMatchObject({ + absoluteTime: 1778522879143, + offsetMs: 240, + requestedKind: "timestamp", + wasClamped: false, + }); + + const clampedTime = resolveTargetTime(archive.metadata, { time: 999_999 }); + expect(clampedTime).toMatchObject({ + absoluteTime: 1778522878903, + offsetMs: 0, + requestedKind: "timestamp", + wasClamped: true, + }); + }); + + it("selects report attempts, resolves snapshot attachments, and picks default times", async () => { + const results = await readResultsFromReport(SAMPLE_REPORT); + const result = findReportTestResult(results, { + name: "failed describe test with image comparison diff", + browser: "chrome", + }); + const attachment = getSnapshotAttachment(result); + const source = await resolveSnapshotAttachmentSource(SAMPLE_REPORT, SAMPLE_REPORT, attachment.path); + const defaultTime = getReportDefaultTime(result); + + expect(result.attempt).toBe(1); + expect(attachment.path).toBe("snapshots/ba3c69a/chrome_1778522876782_1.zip"); + expect(source).toBe(path.join(SAMPLE_REPORT, "snapshots/ba3c69a/chrome_1778522876782_1.zip")); + expect(defaultTime).toEqual({ + absoluteTime: 1778522877240, + reason: "default fail result end", + }); + }); + + it("prefers failed step end as the report default time", () => { + const result = { + status: "fail", + timestamp: 100, + duration: 500, + history: [ + { n: "first", a: [], ts: 110, d: 10 }, + { + n: "group", + a: [], + ts: 130, + d: 100, + g: true, + c: [{ n: "failed action", a: [], ts: 140, d: 20, f: true }], + }, + ], + }; + + expect(getReportDefaultTime(result as never)).toEqual({ + absoluteTime: 160, + reason: 'default failed step "failed action" end', + }); + }); + + it("formats concise report steps from report history", async () => { + const results = await readResultsFromReport(SAMPLE_REPORT); + const result = findReportTestResult(results, { + name: "failed describe test with image comparison diff", + browser: "chrome", + }); + const steps = formatReportTestSteps(result, result.timestamp); + + expect(steps).toContain('Times are offsets from the first rrweb event; use them as "time" values.'); + expect(steps).toContain('- +8ms..+22ms setWindowSize("1280", "1024")'); + expect(steps).toContain('- +235ms..+361ms assertView("header", "header")'); + expect(steps).toContain('- +361ms..+375ms execute("code")'); + }); + + it("formats the full failed step group tree when history marks it", () => { + const result = { + history: [ + { n: "first", a: [], ts: 110, d: 10 }, + { + n: "group", + a: [], + ts: 130, + d: 100, + g: true, + f: true, + c: [{ n: "failed action", a: ["arg"], ts: 140, d: 20, f: true }], + }, + { n: "after", a: [], ts: 250, d: 10 }, + ], + }; + + const steps = formatReportTestSteps(result as never, 100); + + expect(steps).toContain("- +10ms..+20ms first"); + expect(steps).toContain("! +30ms..+130ms group"); + expect(steps).toContain(' ! +40ms..+60ms failed action("arg")'); + expect(steps).toContain("- +150ms..+160ms after"); + }); + + it("preserves ancestor structure when diffing formatted snapshots", () => { + const baseline = { + fenceLanguage: "yaml" as const, + content: [ + "# Note: baseline", + "- body:", + " - main:", + " - section.card:", + ' - span "old"', + " - footer:", + ' - span "same"', + ].join("\n"), + }; + const target = { + fenceLanguage: "yaml" as const, + content: [ + "# Note: target", + "- body:", + " - main:", + " - section.card:", + ' - span "new"', + " - footer:", + ' - span "same"', + ].join("\n"), + }; + + const diff = diffPageSnapshots(baseline, target); + + expect(diff.fenceLanguage).toBe("diff"); + expect(diff.content).toContain("~ body"); + expect(diff.content).toContain(" ..."); + expect(diff.content).toContain(" ~ main"); + expect(diff.content).toContain(" ~ section.card"); + expect(diff.content).toContain(' ~ span "new"'); + expect(diff.content).toContain(' text: "old" -> "new"'); + expect(diff.content).not.toContain("footer"); + }); + + it("returns an empty diff marker when formatted snapshots match", () => { + const snapshot = { + fenceLanguage: "yaml" as const, + content: ["# Note: target", "- body:", " - main:"].join("\n"), + }; + + expect(diffPageSnapshots(snapshot, snapshot)).toEqual({ + fenceLanguage: "diff", + content: "# No DOM nodes changed between the selected times.", + }); + }); + + it("folds parent-only changes and omits unchanged descendants", () => { + const baseline = { + fenceLanguage: "yaml" as const, + content: ["- main.main:", " - div:", ' - span "same"'].join("\n"), + }; + const target = { + fenceLanguage: "yaml" as const, + content: ["- main.main.main_forced:", " - div:", ' - span "same"'].join("\n"), + }; + const diff = diffPageSnapshots(baseline, target); + + expect(diff.content).toContain("~ main.main.main_forced"); + expect(diff.content).toContain(" classes:"); + expect(diff.content).toContain(" + main_forced"); + expect(diff.content).toContain(" children unchanged: 2 nodes omitted"); + expect(diff.content).not.toContain("span"); + }); + + it("renders added subtrees with structure", () => { + const baseline = { + fenceLanguage: "yaml" as const, + content: "- body:", + }; + const target = { + fenceLanguage: "yaml" as const, + content: [ + "- body:", + " - div.HeaderFormSearchThemesSection-Wrapper:", + " - section.SearchThemesSection:", + " - ul.SearchThemesSection-List:", + " - li:", + " - a.Link:", + ' - span "Квартиры"', + ].join("\n"), + }; + const diff = diffPageSnapshots(baseline, target); + + expect(diff.content).toContain("~ body"); + expect(diff.content).toContain(" + div.HeaderFormSearchThemesSection-Wrapper"); + expect(diff.content).toContain(" + section.SearchThemesSection"); + expect(diff.content).toContain(' + span "Квартиры"'); + }); + + it("renders class and attr changes on matched nodes", () => { + const baseline = { + fenceLanguage: "yaml" as const, + content: + '- form.HeaderForm.mini-suggest_has-value_yes[action=/search/ aria-label="Old label" name=yandex role=search]:', + }; + const target = { + fenceLanguage: "yaml" as const, + content: + '- form.HeaderForm.mini-suggest_expanded.HeaderForm_search_themes-visible[action=/search/ aria-label="New label" name=yandex role=search @hidden]:', + }; + const diff = diffPageSnapshots(baseline, target); + + expect(diff.content).toContain( + '~ form.HeaderForm.mini-suggest_expanded.HeaderForm_search_themes-visible[role="search" name="yandex" aria-label="New label"]', + ); + expect(diff.content).toContain(" classes:"); + expect(diff.content).toContain(" - mini-suggest_has-value_yes"); + expect(diff.content).toContain(" + HeaderForm_search_themes-visible"); + expect(diff.content).toContain(" + mini-suggest_expanded"); + expect(diff.content).toContain(" attrs:"); + expect(diff.content).toContain(" + @hidden"); + expect(diff.content).toContain(' ~ aria-label: "Old label" -> "New label"'); + }); + + it("matches similar siblings when generated ids change", () => { + const baseline = { + fenceLanguage: "yaml" as const, + content: ["- body:", " - div.Root#old-generated[role=region]:", ' - span "old"'].join("\n"), + }; + const target = { + fenceLanguage: "yaml" as const, + content: ["- body:", " - div.Root#new-generated[role=region]:", ' - span "new"'].join("\n"), + }; + const diff = diffPageSnapshots(baseline, target); + + expect(diff.content).toContain('~ div.Root#new-generated[role="region"]'); + expect(diff.content).toContain('id: "old-generated" -> "new-generated"'); + expect(diff.content).toContain('~ span "new"'); + expect(diff.content).not.toContain("- div.Root#old-generated"); + expect(diff.content).not.toContain("+ div.Root#new-generated"); + }); + + it("truncates long attr values in rendered diffs", () => { + const longUrl = `https://example.com/search?text=${"x".repeat(200)}`; + const baseline = { + fenceLanguage: "yaml" as const, + content: '- a.Link[href=https://example.com] "Link"', + }; + const target = { + fenceLanguage: "yaml" as const, + content: `- a.Link[href="${longUrl}"] "Link"`, + }; + const diff = diffPageSnapshots(baseline, target); + + expect(diff.content).toContain("..."); + expect(diff.content).not.toContain("x".repeat(160)); + }); + + it("renders a time travel snapshot and captures the rrweb iframe DOM", async () => { + const result = await timeTravelSnapshot.cb( + parseArgs({ + snapshotFile: SAMPLE_SNAPSHOT, + time: 134, + includeAttrs: ["class"], + truncateText: false, + }), + ); + const text = getTextContent(result); + + expect(result.isError).toBe(false); + expect(text).toContain("Time travel snapshot captured"); + expect(text).toContain("## Selected Time"); + expect(text).toContain("Reason: provided offset 134ms from first rrweb event"); + expect(text).toContain("Some header"); + expect(text).toContain("Lorem ipsum dolor sit amet"); + }, 30_000); +}); From 1ab967ce471a334393b4f16a7cfa74df60313200 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 17 May 2026 22:32:57 +0300 Subject: [PATCH 2/2] fix: fix remaining review issues --- package-lock.json | 94 ++++++++++++++++++- packages/cli/test/daemon.e2e.test.ts | 4 +- .../tools/time-travel-snapshot/formatters.ts | 9 +- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index bfa4bf3..7745e9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1382,6 +1382,28 @@ "win32" ] }, + "node_modules/@rrweb/replay": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/@rrweb/replay/-/replay-2.0.0-alpha.20.tgz", + "integrity": "sha512-VodsLb+C2bYNNVbb0U14tKLa9ctzUxYIlt9VnxPATWvfyXHLTku8BhRWptuW/iIjVjmG49LBoR1ilxw/HMiJ1w==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.20", + "rrweb": "^2.0.0-alpha.20" + } + }, + "node_modules/@rrweb/types": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz", + "integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.20.tgz", + "integrity": "sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -2073,6 +2095,12 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2621,6 +2649,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/@zip.js/zip.js": { "version": "2.7.62", "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.62.tgz", @@ -2948,6 +2982,15 @@ } } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4858,6 +4901,12 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8385,6 +8434,40 @@ "node": ">= 18" } }, + "node_modules/rrdom": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.20.tgz", + "integrity": "sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==", + "license": "MIT", + "dependencies": { + "rrweb-snapshot": "^2.0.0-alpha.20" + } + }, + "node_modules/rrweb": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz", + "integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==", + "license": "MIT", + "dependencies": { + "@rrweb/types": "^2.0.0-alpha.20", + "@rrweb/utils": "^2.0.0-alpha.20", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.0.0-alpha.20", + "rrweb-snapshot": "^2.0.0-alpha.20" + } + }, + "node_modules/rrweb-snapshot": { + "version": "2.0.0-alpha.20", + "resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.20.tgz", + "integrity": "sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==", + "license": "MIT", + "dependencies": { + "postcss": "^8.4.38" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10804,10 +10887,11 @@ "version": "0.0.1", "license": "ISC", "dependencies": { + "@rrweb/replay": "^2.0.0-alpha.18", "@testplane/testing-library": "^1.0.5", "commander": "^14.0.3", "debug": "^4.3.4", - "html-reporter": "^11.10.0-rc.1", + "html-reporter": "^11.10.0-rc.2", "testplane": "^8.44.1-rc.1", "zod": "^3.22.4" }, @@ -11303,9 +11387,10 @@ "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.11.2", + "@rrweb/replay": "^2.0.0-alpha.18", "@testplane/testing-library": "^1.0.5", "commander": "^13.1.0", - "html-reporter": "^11.10.0-rc.1", + "html-reporter": "^11.10.0-rc.2", "testplane": "^8.44.1-rc.1", "zod": "^3.22.4" }, @@ -11789,8 +11874,11 @@ "version": "0.0.0", "license": "ISC", "dependencies": { + "@rrweb/replay": "^2.0.0-alpha.18", + "@rrweb/types": "^2.0.0-alpha.18", "@testplane/testing-library": "^1.0.5", - "html-reporter": "^11.10.0-rc.1", + "fflate": "^0.8.2", + "html-reporter": "^11.10.0-rc.2", "lodash.escaperegexp": "^4.1.2", "testplane": "^8.44.1-rc.1", "zod": "^3.22.4" diff --git a/packages/cli/test/daemon.e2e.test.ts b/packages/cli/test/daemon.e2e.test.ts index b742b24..3281892 100644 --- a/packages/cli/test/daemon.e2e.test.ts +++ b/packages/cli/test/daemon.e2e.test.ts @@ -124,7 +124,7 @@ describe("daemon e2e", () => { expect(r.code).toBe(0); expect(r.stdout).toContain(`Successfully navigated to ${playgroundUrl}`); - const snapshotPathMatch = r.stdout.match(/Saved to: (\S+\.(?:yml|html))/); + const snapshotPathMatch = r.stdout.match(/The snapshot was saved to: (\S+\.(?:yml|html))/); expect(snapshotPathMatch, "navigate response should reference a saved snapshot file").not.toBeNull(); const snapshotContent = fs.readFileSync(snapshotPathMatch![1], "utf8"); expect(snapshotContent).toContain("e2e-ok"); @@ -141,7 +141,7 @@ describe("daemon e2e", () => { expect(r.code).toBe(0); expect(r.stdout).toContain('Successfully selected option by value "jp"'); - const snapshotPathMatch = r.stdout.match(/Saved to: (\S+\.(?:yml|html))/); + const snapshotPathMatch = r.stdout.match(/The snapshot was saved to: (\S+\.(?:yml|html))/); expect(snapshotPathMatch, "select response should reference a saved snapshot file").not.toBeNull(); const snapshotContent = fs.readFileSync(snapshotPathMatch![1], "utf8"); expect(snapshotContent).toContain("selected:jp"); diff --git a/packages/tools/src/tools/time-travel-snapshot/formatters.ts b/packages/tools/src/tools/time-travel-snapshot/formatters.ts index a76478a..f54b8a8 100644 --- a/packages/tools/src/tools/time-travel-snapshot/formatters.ts +++ b/packages/tools/src/tools/time-travel-snapshot/formatters.ts @@ -2,6 +2,7 @@ import { ReporterTestResult } from "html-reporter/experimental/sdk"; import { TimeTravelArchive } from "./rrweb-snapshots.js"; import { TimeTravelSnapshotArgs } from "./schema.js"; import { ReporterTestStep, SelectedSnapshotTime, SnapshotInputSelection } from "./types.js"; +import { formatTimestamp } from "../../utils/formatters.js"; function formatOffset(offsetMs: number): string { return offsetMs >= 0 ? `+${offsetMs}ms` : `${offsetMs}ms`; @@ -63,7 +64,7 @@ export function formatReportTestSteps(result: ReporterTestResult, snapshotStartT function formatSelectedTime(selection: SelectedSnapshotTime): string { const lines = [ `Reason: ${selection.reason}`, - `Absolute timestamp: ${selection.absoluteTime} (${new Date(selection.absoluteTime).toISOString()})`, + `Absolute timestamp: ${selection.absoluteTime} (${formatTimestamp(selection.absoluteTime)})`, `Offset from first rrweb event: ${selection.offsetMs}ms`, ]; @@ -72,9 +73,7 @@ function formatSelectedTime(selection: SelectedSnapshotTime): string { } if (selection.wasClamped && selection.unclampedTime !== undefined) { - lines.push( - `Unclamped timestamp: ${selection.unclampedTime} (${new Date(selection.unclampedTime).toISOString()})`, - ); + lines.push(`Unclamped timestamp: ${selection.unclampedTime} (${formatTimestamp(selection.unclampedTime)})`); } return lines.join("\n"); @@ -89,7 +88,7 @@ function formatSourceInfo( `Mode: ${input.mode}`, `Snapshot source: ${archive.source}`, `Events: ${archive.events.length}`, - `Snapshot range: ${archive.metadata.startTime} (${new Date(archive.metadata.startTime).toISOString()}) - ${archive.metadata.endTime} (${new Date(archive.metadata.endTime).toISOString()}); total ${archive.metadata.totalTime}ms`, + `Snapshot range: ${archive.metadata.startTime} (${formatTimestamp(archive.metadata.startTime)}) - ${archive.metadata.endTime} (${formatTimestamp(archive.metadata.endTime)}); total ${archive.metadata.totalTime}ms`, ]; if (input.result) {