From 6a85f19439fbb552999b49336fddeba2d010adc7 Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 17 Sep 2025 01:17:51 +0300 Subject: [PATCH] feat(experimental): implement ability to write test dependencies --- package-lock.json | 13 +- package.json | 2 +- src/browser/cdp/connection.ts | 8 +- src/browser/cdp/domains/css.ts | 66 +++ src/browser/cdp/domains/debugger.ts | 92 ++++ src/browser/cdp/domains/dom.ts | 37 ++ src/browser/cdp/{ => domains}/profiler.ts | 13 +- src/browser/cdp/domains/runtime.ts | 74 +++ src/browser/cdp/{ => domains}/target.ts | 23 +- src/browser/cdp/emitter.ts | 4 + src/browser/cdp/index.ts | 30 +- src/browser/cdp/selectivity/constants.ts | 1 + .../cdp/selectivity/css-selectivity.ts | 153 ++++++ .../cdp/selectivity/file-hash-provider.ts | 29 ++ .../cdp/selectivity/file-hash-writer.ts | 165 ++++++ src/browser/cdp/selectivity/index.ts | 63 +++ src/browser/cdp/selectivity/js-selectivity.ts | 178 +++++++ .../selectivity/test-dependencies-writer.ts | 72 +++ src/browser/cdp/selectivity/types.ts | 8 + src/browser/cdp/selectivity/utils.ts | 234 +++++++++ src/browser/cdp/types.ts | 105 +++- src/browser/existing-browser.ts | 4 + src/config/browser-options.js | 8 + src/config/defaults.js | 5 + src/config/types.ts | 15 +- src/error-snippets/constants.ts | 3 +- src/error-snippets/source-maps.ts | 10 +- src/error-snippets/utils.ts | 6 +- src/worker/runner/test-runner/index.js | 5 + test/src/browser/cdp/connection.ts | 38 +- .../cdp/selectivity/css-selectivity.ts | 491 ++++++++++++++++++ .../cdp/selectivity/file-hash-provider.ts | 187 +++++++ .../cdp/selectivity/file-hash-writer.ts | 309 +++++++++++ test/src/browser/cdp/selectivity/index.ts | 273 ++++++++++ .../browser/cdp/selectivity/js-selectivity.ts | 437 ++++++++++++++++ .../selectivity/test-dependencies-writer.ts | 297 +++++++++++ test/src/browser/cdp/selectivity/utils.ts | 363 +++++++++++++ test/src/worker/runner/test-runner/index.js | 59 +++ 38 files changed, 3815 insertions(+), 65 deletions(-) create mode 100644 src/browser/cdp/domains/css.ts create mode 100644 src/browser/cdp/domains/debugger.ts create mode 100644 src/browser/cdp/domains/dom.ts rename src/browser/cdp/{ => domains}/profiler.ts (88%) create mode 100644 src/browser/cdp/domains/runtime.ts rename src/browser/cdp/{ => domains}/target.ts (81%) create mode 100644 src/browser/cdp/selectivity/constants.ts create mode 100644 src/browser/cdp/selectivity/css-selectivity.ts create mode 100644 src/browser/cdp/selectivity/file-hash-provider.ts create mode 100644 src/browser/cdp/selectivity/file-hash-writer.ts create mode 100644 src/browser/cdp/selectivity/index.ts create mode 100644 src/browser/cdp/selectivity/js-selectivity.ts create mode 100644 src/browser/cdp/selectivity/test-dependencies-writer.ts create mode 100644 src/browser/cdp/selectivity/types.ts create mode 100644 src/browser/cdp/selectivity/utils.ts create mode 100644 test/src/browser/cdp/selectivity/css-selectivity.ts create mode 100644 test/src/browser/cdp/selectivity/file-hash-provider.ts create mode 100644 test/src/browser/cdp/selectivity/file-hash-writer.ts create mode 100644 test/src/browser/cdp/selectivity/index.ts create mode 100644 test/src/browser/cdp/selectivity/js-selectivity.ts create mode 100644 test/src/browser/cdp/selectivity/test-dependencies-writer.ts create mode 100644 test/src/browser/cdp/selectivity/utils.ts diff --git a/package-lock.json b/package-lock.json index 53620cf28..42c3ce10d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", - "source-map": "0.7.4", + "source-map": "0.7.6", "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", @@ -12129,10 +12129,11 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/source-map": { - "version": "0.7.4", - "license": "BSD-3-Clause", + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -22032,7 +22033,9 @@ } }, "source-map": { - "version": "0.7.4" + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==" }, "source-map-js": { "version": "1.2.0" diff --git a/package.json b/package.json index bef717f52..a8b0b7507 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "sizzle": "2.3.6", "socket.io": "4.7.5", "socket.io-client": "4.7.5", - "source-map": "0.7.4", + "source-map": "0.7.6", "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", diff --git a/src/browser/cdp/connection.ts b/src/browser/cdp/connection.ts index d446e6e8b..f694bf0f4 100644 --- a/src/browser/cdp/connection.ts +++ b/src/browser/cdp/connection.ts @@ -178,7 +178,7 @@ export class CDPConnection { this._pingHealthCheckStart(); const onPing = (): void => result.pong(); - const _onMessage = (data: RawData): void => this._onMessage(data); + const onMessage = (data: RawData): void => this._onMessage(data); const onError = (err: Error): void => { if (result === this._wsConnection) { this._closeWsConnection( @@ -190,11 +190,11 @@ export class CDPConnection { }; result.on("ping", onPing); - result.on("message", _onMessage); + result.on("message", onMessage); result.on("error", onError); result.once("close", () => { result.off("ping", onPing); - result.off("message", _onMessage); + result.off("message", onMessage); result.off("error", onError); if (result === this._wsConnection) { this._closeWsConnection( @@ -388,7 +388,7 @@ export class CDPConnection { } /** @description Performs high-level CDP request with retries and timeouts */ - async request( + async request( method: CDPRequest["method"], { params, sessionId }: Omit = {}, ): Promise { diff --git a/src/browser/cdp/domains/css.ts b/src/browser/cdp/domains/css.ts new file mode 100644 index 000000000..c391d319b --- /dev/null +++ b/src/browser/cdp/domains/css.ts @@ -0,0 +1,66 @@ +import { CDPConnection } from "../connection"; +import { CDPEventEmitter } from "../emitter"; +import type { CDPCSSStyleSheetHeader, CDPSessionId, CDPStyleSheetId } from "../types"; + +interface StopRuleUsageTrackingResponse { + ruleUsage: Array<{ + styleSheetId: CDPStyleSheetId; + startOffset: number; + endOffset: number; + used: boolean; + }>; +} + +export interface CssEvents { + fontsUpdated: { + font: Record; + }; + mediaQueryResultChanged: Record; + styleSheetAdded: { + header: CDPCSSStyleSheetHeader; + }; + styleSheetChanged: { + styleSheetId: CDPStyleSheetId; + }; + styleSheetRemoved: { + styleSheetId: CDPStyleSheetId; + }; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/ */ +export class CDPCss extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-enable */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("CSS.enable", { sessionId }); + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-disable */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("CSS.disable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-getStyleSheetText */ + async getStyleSheetText(sessionId: CDPSessionId, styleSheetId: CDPStyleSheetId): Promise<{ text: string }> { + return this._connection.request("CSS.getStyleSheetText", { sessionId, params: { styleSheetId } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-startRuleUsageTracking */ + async startRuleUsageTracking(sessionId: CDPSessionId): Promise { + return this._connection.request("CSS.startRuleUsageTracking", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/tot/CSS/#method-stopRuleUsageTracking */ + async stopRuleUsageTracking(sessionId: CDPSessionId): Promise { + return this._connection.request("CSS.stopRuleUsageTracking", { sessionId }); + } +} diff --git a/src/browser/cdp/domains/debugger.ts b/src/browser/cdp/domains/debugger.ts new file mode 100644 index 000000000..0f2db8dd4 --- /dev/null +++ b/src/browser/cdp/domains/debugger.ts @@ -0,0 +1,92 @@ +import { CDPConnection } from "../connection"; +import { CDPEventEmitter } from "../emitter"; +import type { + CDPDebuggerCallFrame, + CDPDebuggerPausedReason, + CDPExecutionContextId, + CDPRuntimeScriptId, + CDPRuntimeStackTrace, + CDPSessionId, +} from "../types"; + +interface ScriptData { + scriptId: CDPRuntimeScriptId; + /** URL or name of the script parsed (if any). */ + url: string; + /** Line offset of the script within the resource with given URL (for script tags). */ + startLine: number; + /** Column offset of the script within the resource with given URL. */ + startColumn: number; + /** Last line of the script. */ + endLine: number; + /** Length of the last line of the script. */ + endColumn: number; + executionContextId: CDPExecutionContextId; + /** Content hash of the script, SHA-256. */ + hash: string; + /** For Wasm modules, the content of the build_id custom section. For JavaScript the debugId magic comment. */ + buildId: string; + /** Embedder-specific auxiliary data likely matching {isDefault: boolean, type: 'default'|'isolated'|'worker', frameId: string} */ + executionContextAuxData?: Record; + sourceMapURL?: string; + /** True, if this script has sourceURL. */ + hasSourceURL?: boolean; + /** True, if this script is ES6 module. */ + isModule?: boolean; + /** This script length. */ + length?: number; +} + +interface GetScriptSourceResponse { + /** Script source (empty in case of Wasm bytecode). */ + scriptSource: string; + /** Wasm bytecode. (Encoded as a base64 string when passed over JSON) */ + bytecode?: string; +} + +export interface DebuggerEvents { + paused: { + callFrames: CDPDebuggerCallFrame; + /** Location of console.profileEnd(). */ + reason: CDPDebuggerPausedReason; + /** Object containing break-specific auxiliary properties. */ + data?: Record; + asyncStackTrace?: CDPRuntimeStackTrace; + }; + resumed: Record; + scriptFailedToParse: ScriptData; + scriptParsed: ScriptData; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Debugger/ */ +export class CDPDebugger extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Debugger/#method-disable */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Debugger.disable", { sessionId }); + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Debugger/#method-enable */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("Debugger.enable", { sessionId }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Debugger/#method-resume */ + async resume(sessionId: CDPSessionId, terminateOnResume?: boolean): Promise { + return this._connection.request("Debugger.resume", { sessionId, params: { terminateOnResume } }); + } + + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Debugger/#method-getScriptSource */ + async getScriptSource(sessionId: CDPSessionId, scriptId: CDPRuntimeScriptId): Promise { + return this._connection.request("Debugger.getScriptSource", { sessionId, params: { scriptId } }); + } +} diff --git a/src/browser/cdp/domains/dom.ts b/src/browser/cdp/domains/dom.ts new file mode 100644 index 000000000..12ea2c869 --- /dev/null +++ b/src/browser/cdp/domains/dom.ts @@ -0,0 +1,37 @@ +import { CDPConnection } from "../connection"; +import { CDPEventEmitter } from "../emitter"; +import type { CDPSessionId } from "../types"; + +export interface DomEvents { + attributeModified: Record; + attributeRemoved: Record; + characterDataModified: Record; + childNodeCountUpdated: Record; + childNodeInserted: Record; + childNodeRemoved: Record; + documentUpdated: Record; + setChildNodes: Record; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Dom/ */ +export class CDPDom extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/DOM/#method-enable */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("DOM.enable", { sessionId }); + } + + /** @param sessionId result of "Target.attachToTarget" */ + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/DOM/#method-disable */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("DOM.disable", { sessionId }); + } +} diff --git a/src/browser/cdp/profiler.ts b/src/browser/cdp/domains/profiler.ts similarity index 88% rename from src/browser/cdp/profiler.ts rename to src/browser/cdp/domains/profiler.ts index 1816a41b7..d34f7e631 100644 --- a/src/browser/cdp/profiler.ts +++ b/src/browser/cdp/domains/profiler.ts @@ -1,6 +1,6 @@ -import { CDPConnection } from "./connection"; -import { CDPEventEmitter } from "./emitter"; -import type { CDPDebuggerLocation, CDPSessionId, CDPScriptCoverage, CDPProfile } from "./types"; +import { CDPConnection } from "../connection"; +import { CDPEventEmitter } from "../emitter"; +import type { CDPDebuggerLocation, CDPSessionId, CDPScriptCoverage, CDPProfile } from "../types"; interface StartPreciseCoverageRequest { /** Collect accurate call counts beyond simple 'covered' or 'not covered'. */ @@ -72,10 +72,7 @@ export class CDPProfiler extends CDPEventEmitter { sessionId: CDPSessionId, params: StartPreciseCoverageRequest, ): Promise { - return this._connection.request("Profiler.startPreciseCoverage", { - sessionId, - params, - }); + return this._connection.request("Profiler.startPreciseCoverage", { sessionId, params }); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-stopPreciseCoverage */ @@ -85,6 +82,6 @@ export class CDPProfiler extends CDPEventEmitter { /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Profiler/#method-takePreciseCoverage */ async takePreciseCoverage(sessionId: CDPSessionId): Promise { - return this._connection.request("Profiler.takePreciseCoverage", { sessionId }); + return this._connection.request("Profiler.takePreciseCoverage", { sessionId }); } } diff --git a/src/browser/cdp/domains/runtime.ts b/src/browser/cdp/domains/runtime.ts new file mode 100644 index 000000000..f47edc20f --- /dev/null +++ b/src/browser/cdp/domains/runtime.ts @@ -0,0 +1,74 @@ +import { CDPConnection } from "../connection"; +import { CDPEventEmitter } from "../emitter"; +import type { CDPExecutionContextId, CDPRuntimeRemoteObject, CDPSessionId } from "../types"; + +interface EvaluateRequest { + /** Expression to evaluate. */ + expression: string; + /** Symbolic group name that can be used to release multiple objects. */ + objectGroup?: string; + /** Determines whether Command Line API should be available during the evaluation. */ + includeCommandLineAPI?: boolean; + /** In silent mode exceptions thrown during evaluation are not reported and do not pause execution. Overrides setPauseOnException state. */ + silent?: boolean; + /** Specifies in which execution context to perform evaluation. If the parameter is omitted the evaluation will be performed in the context of the inspected page. This is mutually exclusive with uniqueContextId, which offers an alternative way to identify the execution context that is more reliable in a multi-process environment. */ + contextId?: CDPExecutionContextId; + /** Whether the result is expected to be a JSON object that should be sent by value. */ + returnByValue?: boolean; + /** Whether execution should be treated as initiated by user in the UI. */ + userGesture?: boolean; + /** Whether execution should await for resulting value and return once awaited promise is resolved. */ + awaitPromise?: boolean; +} + +interface EvaluateResponse { + /** Evaluation result. */ + result: CDPRuntimeRemoteObject; + /** Exception details. */ + exceptionDetails?: Record; +} + +export interface RuntimeEvents { + consoleAPICalled: Record; + exceptionRevoked: Record; + exceptionThrown: Record; + executionContextCreated: Record; + executionContextDestroyed: Record; + executionContextsCleared: Record; + inspectRequested: Record; +} + +/** @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/ */ +export class CDPRuntime extends CDPEventEmitter { + private readonly _connection: CDPConnection; + + public constructor(connection: CDPConnection) { + super(); + + this._connection = connection; + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#method-evaluate + */ + async evaluate(sessionId: CDPSessionId, params: EvaluateRequest): Promise> { + return this._connection.request("Runtime.evaluate", { sessionId, params }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#method-enable + */ + async enable(sessionId: CDPSessionId): Promise { + return this._connection.request("Runtime.enable", { sessionId }); + } + + /** + * @param sessionId result of "Target.attachToTarget" + * @link https://chromedevtools.github.io/devtools-protocol/1-3/Runtime/#method-disable + */ + async disable(sessionId: CDPSessionId): Promise { + return this._connection.request("Runtime.disable", { sessionId }); + } +} diff --git a/src/browser/cdp/target.ts b/src/browser/cdp/domains/target.ts similarity index 81% rename from src/browser/cdp/target.ts rename to src/browser/cdp/domains/target.ts index 0201fb20e..2ac32d65b 100644 --- a/src/browser/cdp/target.ts +++ b/src/browser/cdp/domains/target.ts @@ -1,6 +1,6 @@ -import { CDPEventEmitter } from "./emitter"; -import { CDPConnection } from "./connection"; -import type { CDPBrowserContextId, CDPSessionId, CDPTargetId, CDPTargetInfo } from "./types"; +import { CDPEventEmitter } from "../emitter"; +import { CDPConnection } from "../connection"; +import type { CDPBrowserContextId, CDPSessionId, CDPTargetId, CDPTargetInfo } from "../types"; interface GetBrowserContextsResponse { browserContextIds: CDPBrowserContextId[]; @@ -67,12 +67,12 @@ export class CDPTarget extends CDPEventEmitter { /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-getBrowserContexts */ async getBrowserContexts(): Promise { - return this._connection.request("Target.getBrowserContexts"); + return this._connection.request("Target.getBrowserContexts"); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-createBrowserContext */ async createBrowserContext(): Promise { - return this._connection.request("Target.createBrowserContext"); + return this._connection.request("Target.createBrowserContext"); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-disposeBrowserContext */ @@ -88,9 +88,7 @@ export class CDPTarget extends CDPEventEmitter { url?: string; browserContextId?: CDPBrowserContextId; }): Promise { - const params = { url, browserContextId }; - - return this._connection.request("Target.createTarget", { params }); + return this._connection.request("Target.createTarget", { params: { url, browserContextId } }); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-closeTarget */ @@ -105,14 +103,17 @@ export class CDPTarget extends CDPEventEmitter { /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-attachToTarget */ async attachToTarget(targetId: CDPTargetId): Promise { - const params = { targetId, flatten: true }; + return this._connection.request("Target.attachToTarget", { params: { targetId, flatten: true } }); + } - return this._connection.request("Target.attachToTarget", { params }); + /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-detachFromTarget */ + async detachFromTarget(sessionId: CDPSessionId): Promise { + return this._connection.request("Target.detachFromTarget", { params: { sessionId } }); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-getTargets */ async getTargets(): Promise { - return this._connection.request("Target.getTargets"); + return this._connection.request("Target.getTargets"); } /** @link https://chromedevtools.github.io/devtools-protocol/1-3/Target/#method-setAutoAttach */ diff --git a/src/browser/cdp/emitter.ts b/src/browser/cdp/emitter.ts index 3dbcf04f9..0c28fd0fe 100644 --- a/src/browser/cdp/emitter.ts +++ b/src/browser/cdp/emitter.ts @@ -20,4 +20,8 @@ export class CDPEventEmitter return super.on(event, eventListenerWithErrorBoundary); } + + off(event: U, listener: (params: Events[U]) => void | Promise): this { + return super.off(event, listener); + } } diff --git a/src/browser/cdp/index.ts b/src/browser/cdp/index.ts index 3b3941d1b..7837d7951 100644 --- a/src/browser/cdp/index.ts +++ b/src/browser/cdp/index.ts @@ -1,13 +1,21 @@ import type { Browser } from "../types"; -import { CDPConnection } from "./connection"; -import { CDPTarget } from "./target"; -import { CDPProfiler } from "./profiler"; import type { CDPEvent } from "./types"; +import { CDPConnection } from "./connection"; +import { CDPTarget } from "./domains/target"; +import { CDPProfiler } from "./domains/profiler"; +import { CDPDebugger } from "./domains/debugger"; +import { CDPRuntime } from "./domains/runtime"; +import { CDPDom } from "./domains/dom"; +import { CDPCss } from "./domains/css"; export class CDP { private readonly _connection: CDPConnection; public readonly target: CDPTarget; public readonly profiler: CDPProfiler; + public readonly debugger: CDPDebugger; + public readonly runtime: CDPRuntime; + public readonly dom: CDPDom; + public readonly css: CDPCss; static async create(browser: Browser): Promise { if (!browser.publicAPI.isChromium) { @@ -23,6 +31,10 @@ export class CDP { this._connection = connection; this.target = new CDPTarget(connection); this.profiler = new CDPProfiler(connection); + this.debugger = new CDPDebugger(connection); + this.runtime = new CDPRuntime(connection); + this.dom = new CDPDom(connection); + this.css = new CDPCss(connection); this._connection.onEventMessage = this._onEventMessage.bind(this); } @@ -40,6 +52,18 @@ export class CDP { case "Profiler": this.profiler.emit(method, cdpEventMessage.params); break; + case "Debugger": + this.debugger.emit(method, cdpEventMessage.params); + break; + case "Runtime": + this.runtime.emit(method, cdpEventMessage.params); + break; + case "DOM": + this.dom.emit(method, cdpEventMessage.params); + break; + case "CSS": + this.css.emit(method, cdpEventMessage.params); + break; } } } diff --git a/src/browser/cdp/selectivity/constants.ts b/src/browser/cdp/selectivity/constants.ts new file mode 100644 index 000000000..e247a5369 --- /dev/null +++ b/src/browser/cdp/selectivity/constants.ts @@ -0,0 +1 @@ +export const WEBPACK_PROTOCOL = "webpack://"; diff --git a/src/browser/cdp/selectivity/css-selectivity.ts b/src/browser/cdp/selectivity/css-selectivity.ts new file mode 100644 index 000000000..707277bfd --- /dev/null +++ b/src/browser/cdp/selectivity/css-selectivity.ts @@ -0,0 +1,153 @@ +import { groupBy } from "lodash"; +import path from "node:path"; +import { resolve as urlResolve, URL } from "node:url"; +import { CSS_SOURCE_MAP_URL_COMMENT } from "../../../error-snippets/constants"; +import { fetchTextWithBrowserFallback, hasProtocol, patchSourceMapSources } from "./utils"; +import type { CDP } from ".."; +import type { CDPRuntimeScriptId, CDPSessionId } from "../types"; +import type { CssEvents } from "../domains/css"; + +export class CSSSelectivity { + private readonly _cdp: CDP; + private readonly _sessionId: CDPSessionId; + private readonly _sourceRoot: string; + private _cssOnStyleSheetAddedFn: ((params: CssEvents["styleSheetAdded"]) => void) | null = null; + private _stylesSourceMap: Record> = {}; + + constructor(cdp: CDP, sessionId: CDPSessionId, sourceRoot = "") { + this._cdp = cdp; + this._sessionId = sessionId; + this._sourceRoot = sourceRoot; + } + + private _processStyle({ header: { styleSheetId, sourceURL, sourceMapURL } }: CssEvents["styleSheetAdded"]): void { + if (!this._sessionId) { + return; + } + + if (!sourceURL || !sourceMapURL) { + this._stylesSourceMap[styleSheetId] ||= null; + return; + } + + this._stylesSourceMap[styleSheetId] ||= fetchTextWithBrowserFallback( + urlResolve(sourceURL, sourceMapURL), + this._cdp.runtime, + this._sessionId, + ); + } + + async start(): Promise { + const cssOnStyleSheetAdded = (this._cssOnStyleSheetAddedFn = this._processStyle.bind(this)); + + this._cdp.css.on("styleSheetAdded", cssOnStyleSheetAdded); + + await Promise.all([ + this._cdp.target.setAutoAttach(this._sessionId, { autoAttach: true, waitForDebuggerOnStart: false }), + this._cdp.dom + .enable(this._sessionId) + .then(() => this._cdp.css.enable(this._sessionId)) + .then(() => this._cdp.css.startRuleUsageTracking(this._sessionId)), + ]); + } + + /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ + async stop(drop?: boolean): Promise { + if (drop) { + this._cssOnStyleSheetAddedFn && this._cdp.css.off("styleSheetAdded", this._cssOnStyleSheetAddedFn); + + return []; + } + + const coverage = await this._cdp.css.stopRuleUsageTracking(this._sessionId); + + // If we haven't got "styleSheetAdded" event for the script, pull up styles + source map manually + coverage.ruleUsage.forEach(({ styleSheetId }) => { + if (Object.hasOwn(this._stylesSourceMap, styleSheetId)) { + return; + } + + const scriptSourcePromise = this._cdp.css + .getStyleSheetText(this._sessionId, styleSheetId) + .then(res => res.text) + .catch((err: Error) => err); + + this._stylesSourceMap[styleSheetId] ||= scriptSourcePromise.then(sourceCode => { + if (sourceCode instanceof Error) { + return sourceCode; + } + + const sourceMapsStartIndex = sourceCode.lastIndexOf(CSS_SOURCE_MAP_URL_COMMENT); + const sourceMapsEndIndex = sourceCode.indexOf("*/", sourceMapsStartIndex); + + if (sourceMapsStartIndex === -1) { + return new Error("Source maping url comment is missing"); + } + + const sourceMapURL = + sourceMapsEndIndex === -1 + ? sourceCode.slice(sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length) + : sourceCode.slice( + sourceMapsStartIndex + CSS_SOURCE_MAP_URL_COMMENT.length, + sourceMapsEndIndex, + ); + + const isSourceMapEmbedded = new URL(sourceMapURL).protocol === "data:"; + + // If we encounter css stylesheet, that was not reported by "styleSheetAdded" + // We can only get sourcemaps if they are inlined + // Otherwise, we can't resolve actual sourcemaps url because we dont know css styles url itself. + if (!isSourceMapEmbedded) { + return new Error( + [ + `Missed stylesheet url for stylesheet id ${styleSheetId}.`, + "Switching to inline sourcemaps for CSS will help", + ].join("\n"), + ); + } + + return fetchTextWithBrowserFallback(sourceMapURL, this._cdp.runtime, this._sessionId); + }); + }); + + const totalDependingSourceFiles = new Set(); + const grouppedByStyleSheetCoverage = groupBy(coverage.ruleUsage, "styleSheetId"); + const styleSheetIds = Object.keys(grouppedByStyleSheetCoverage); + + await Promise.all( + styleSheetIds.map(async styleSheetId => { + const sourceMapString = await this._stylesSourceMap[styleSheetId]; + + if (!sourceMapString) { + return; + } + + if (sourceMapString instanceof Error) { + throw new Error( + `CSS Selectivity: Couldn't load source maps for stylesheet id ${styleSheetId}: ${sourceMapString}`, + ); + } + + const rawSourceMap = patchSourceMapSources(JSON.parse(sourceMapString), this._sourceRoot); + + // We could check "if stylesheet was used" with utils.extractSourceFilesDeps + // But we dont, because if stylesheet was not used, it could be used after change + // So its safe to think "if stylesheet was loaded, it was used" + rawSourceMap.sources.forEach(sourceFilePath => { + // "Each entry is either a string that is a (potentially relative) URL", so we are using posix.jojn + // https://tc39.es/ecma426/#sec-source-map-format + // Except for file path with protocol ("turbopack://", "file://") + const sourceRootBasedPath = hasProtocol(sourceFilePath) + ? sourceFilePath + : path.posix.join(rawSourceMap.sourceRoot || "", sourceFilePath); + + totalDependingSourceFiles.add(sourceRootBasedPath); + }); + }), + ); + + this._cssOnStyleSheetAddedFn && this._cdp.css.off("styleSheetAdded", this._cssOnStyleSheetAddedFn); + + return Array.from(totalDependingSourceFiles).sort(); + } +} diff --git a/src/browser/cdp/selectivity/file-hash-provider.ts b/src/browser/cdp/selectivity/file-hash-provider.ts new file mode 100644 index 000000000..5453eeab1 --- /dev/null +++ b/src/browser/cdp/selectivity/file-hash-provider.ts @@ -0,0 +1,29 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; + +export class FileHashProvider { + private static readonly _hashStore: Map> = new Map(); + + async calculateFor(filePath: string): Promise { + const cachedHash = FileHashProvider._hashStore.get(filePath); + + if (cachedHash) { + return cachedHash; + } + + const hash = new Promise((resolve, reject) => { + const hash = crypto.createHash("md5"); + const fileReadStream = fs.createReadStream(filePath); + + fileReadStream.on("data", chunk => hash.update(chunk)); + fileReadStream.on("end", () => resolve(hash.digest("hex"))); + fileReadStream.on("error", err => + reject(new Error(`Selectivity: Couldn't calculate hash for ${filePath}: ${err}`)), + ); + }); + + FileHashProvider._hashStore.set(filePath, hash); + + return hash; + } +} diff --git a/src/browser/cdp/selectivity/file-hash-writer.ts b/src/browser/cdp/selectivity/file-hash-writer.ts new file mode 100644 index 000000000..e3e3a86eb --- /dev/null +++ b/src/browser/cdp/selectivity/file-hash-writer.ts @@ -0,0 +1,165 @@ +import { memoize } from "lodash"; +import path from "node:path"; +import fs from "fs-extra"; +import { FileHashProvider } from "./file-hash-provider"; +import { shallowSortObject } from "./utils"; +import type { NormalizedDependencies } from "./types"; + +export class FileHashWriter { + private readonly _fileHashProvider = new FileHashProvider(); + // "null" - successfully writed, "Promise" - file/module hash, "Promise" - calculating hash error + private readonly _stagedFileHashes = new Map>(); + private readonly _stagedModuleHashes = new Map>(); + private readonly _selectivityHashesPath: string; + private _hashFileContents: Promise<{ files: Record; modules: Record }> | null = + null; + + constructor(selectivityRootPath: string) { + this._selectivityHashesPath = path.join(selectivityRootPath, "hashes.json"); + } + + private _addFileDependency(filePath: string): void { + if (this._stagedFileHashes.has(filePath)) { + return; + } + + const value = this._fileHashProvider.calculateFor(filePath).catch(err => err); + + this._stagedFileHashes.set(filePath, value); + } + private _addModuleDependency(modulePath: string): void { + if (this._stagedModuleHashes.has(modulePath)) { + return; + } + + const filePath = path.join(modulePath, "package.json"); + const value = this._fileHashProvider.calculateFor(filePath).catch(err => err); + + this._stagedModuleHashes.set(modulePath, value); + } + + private _ensureInited(): Promise<{ files: Record; modules: Record }> { + if (this._hashFileContents) { + return this._hashFileContents; + } + + return (this._hashFileContents = fs + .exists(this._selectivityHashesPath) + .then(exists => { + return exists ? fs.readFile(this._selectivityHashesPath, "utf8") : ""; + }) + .then(contents => { + return contents ? JSON.parse(contents) : {}; + }) + .catch(() => ({})) + .then(res => { + res.files ||= {}; + res.modules ||= {}; + + return res; + })); + } + + add(dependencies: NormalizedDependencies): void { + this._ensureInited(); + + dependencies.css.forEach(dependency => this._addFileDependency(dependency)); + dependencies.js.forEach(dependency => this._addFileDependency(dependency)); + dependencies.modules.forEach(dependency => this._addModuleDependency(dependency)); + } + + async commit(): Promise { + const wasInited = Boolean(this._hashFileContents); + const hasStaged = Boolean(this._stagedFileHashes.size || this._stagedModuleHashes.size); + + if (!wasInited || !hasStaged) { + return; + } + + const stagedModuleNames = Array.from(this._stagedModuleHashes.keys()); + const stagedFileNames = Array.from(this._stagedFileHashes.keys()); + + const existingHashesContent = await this._ensureInited(); + + const filterMatchingHashes = async ( + keys: string[], + src: Map>, + dest: Record, + ): Promise => { + const remainingKeys: string[] = []; + + for (const key of keys) { + const oldValue = dest[key]; + const newValue = await src.get(key); + + if (newValue === null) { + continue; + } + + if (newValue === oldValue) { + src.set(key, null); + } else { + remainingKeys.push(key); + } + } + + return remainingKeys; + }; + + const writeTo = async ( + keys: string[], + src: Map>, + dest: Record, + ): Promise => { + let needsReSort = false; + + for (const key of keys) { + const hash = await src.get(key); + + if (!hash) { + continue; + } + + if (hash instanceof Error) { + throw hash; + } + + needsReSort = needsReSort || !Object.hasOwn(dest, key); + + dest[key] = hash; + } + + if (needsReSort) { + shallowSortObject(dest); + } + }; + + const markAsCommited = (keys: string[], src: Map>): void => { + keys.forEach(key => src.set(key, null)); + }; + + const [updatedModules, updatedFiles] = await Promise.all([ + filterMatchingHashes(stagedModuleNames, this._stagedModuleHashes, existingHashesContent.modules), + filterMatchingHashes(stagedFileNames, this._stagedFileHashes, existingHashesContent.files), + ]); + + if (!updatedFiles.length && !updatedModules.length) { + return; + } + + await Promise.all([ + writeTo(updatedModules, this._stagedModuleHashes, existingHashesContent.modules), + writeTo(updatedFiles, this._stagedFileHashes, existingHashesContent.files), + ]); + + // Writing pretty json to avoid vcs merge conflicts + await fs.outputJSON(this._selectivityHashesPath, existingHashesContent, { spaces: 2 }); + + markAsCommited(updatedModules, this._stagedModuleHashes); + markAsCommited(updatedFiles, this._stagedFileHashes); + } +} + +export const getFileHashWriter = memoize((selectivityRootPath: string): FileHashWriter => { + return new FileHashWriter(selectivityRootPath); +}); diff --git a/src/browser/cdp/selectivity/index.ts b/src/browser/cdp/selectivity/index.ts new file mode 100644 index 000000000..b2ddc0e48 --- /dev/null +++ b/src/browser/cdp/selectivity/index.ts @@ -0,0 +1,63 @@ +import { CSSSelectivity } from "./css-selectivity"; +import { JSSelectivity } from "./js-selectivity"; +import type { ExistingBrowser } from "../../existing-browser"; +import { getTestDependenciesWriter } from "./test-dependencies-writer"; +import type { Test } from "../../../types"; +import { transformSourceDependencies } from "./utils"; +import { getFileHashWriter } from "./file-hash-writer"; + +type StopSelectivityFn = (test: Test, shouldWrite: boolean) => Promise; + +export const startSelectivity = async (browser: ExistingBrowser): Promise => { + if (!browser.config.selectivity.enabled || !browser.publicAPI.isChromium) { + return () => Promise.resolve(); + } + + if (!browser.cdp) { + throw new Error("Selectivity: Devtools connection is not established, couldn't record selectivity without it"); + } + + const cdpTaget = browser.cdp.target; + const handle = await browser.publicAPI.getWindowHandle(); + const { targetInfos } = await cdpTaget.getTargets(); + const cdpTargetId = targetInfos.find(t => handle.includes(t.targetId))?.targetId; + + if (!cdpTargetId) { + throw new Error( + [ + "Selectivity: Couldn't find current page;", + `\n\t- webdriver handle: ${handle}`, + `\n\t- cdp targets: ${targetInfos.map(t => `"${t.targetId}"`).join(", ")}`, + ].join(""), + ); + } + + const sessionId = await cdpTaget.attachToTarget(cdpTargetId).then(r => r.sessionId); + + const cssSelectivity = new CSSSelectivity(browser.cdp, sessionId, browser.config.selectivity.sourceRoot); + const jsSelectivity = new JSSelectivity(browser.cdp, sessionId, browser.config.selectivity.sourceRoot); + + await Promise.all([cssSelectivity.start(), jsSelectivity.start()]); + + /** @param drop only performs cleanup without writing anything. Should be "true" if test is failed */ + return async function stopSelectivity(test: Test, drop: boolean): Promise { + const [cssDependencies, jsDependencies] = await Promise.all([ + cssSelectivity.stop(drop), + jsSelectivity.stop(drop), + ]); + + cdpTaget.detachFromTarget(sessionId).catch(() => {}); + + if (drop || (!cssDependencies.length && !jsDependencies.length)) { + return; + } + + const testDependencyWriter = getTestDependenciesWriter(browser.config.selectivity.testDependenciesPath); + const hashWriter = getFileHashWriter(browser.config.selectivity.testDependenciesPath); + const dependencies = transformSourceDependencies(cssDependencies, jsDependencies); + + hashWriter.add(dependencies); + + await Promise.all([testDependencyWriter.saveFor(test, dependencies), hashWriter.commit()]); + }; +}; diff --git a/src/browser/cdp/selectivity/js-selectivity.ts b/src/browser/cdp/selectivity/js-selectivity.ts new file mode 100644 index 000000000..3fa95412c --- /dev/null +++ b/src/browser/cdp/selectivity/js-selectivity.ts @@ -0,0 +1,178 @@ +import { groupBy } from "lodash"; +import { resolve as urlResolve } from "node:url"; +import type { CDP } from ".."; +import type { DebuggerEvents } from "../domains/debugger"; +import type { CDPRuntimeScriptId, CDPSessionId } from "../types"; +import { JS_SOURCE_MAP_URL_COMMENT } from "../../../error-snippets/constants"; +import { extractSourceFilesDeps, fetchTextWithBrowserFallback } from "./utils"; + +const SOURCE_CODE_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]; + +const isSourceCodeFile = (sourceFileName: string): boolean => { + return SOURCE_CODE_EXTENSIONS.some(ext => sourceFileName.endsWith(ext)); +}; + +export class JSSelectivity { + private readonly _cdp: CDP; + private readonly _sessionId: CDPSessionId; + private readonly _sourceRoot: string; + private _debuggerOnPausedFn: (() => void) | null = null; + private _debuggerOnScriptParsedFn: ((params: DebuggerEvents["scriptParsed"]) => void) | null = null; + private _scriptsSource: Record> = {}; + private _scriptsSourceMap: Record> = {}; + + constructor(cdp: CDP, sessionId: CDPSessionId, sourceRoot = "") { + this._cdp = cdp; + this._sessionId = sessionId; + this._sourceRoot = sourceRoot; + } + + private _processScript({ scriptId, url, sourceMapURL }: DebuggerEvents["scriptParsed"]): void { + if (!this._sessionId) { + return; + } + + if (!url || !sourceMapURL) { + this._scriptsSource[scriptId] ||= null; + this._scriptsSourceMap[scriptId] ||= null; + return; + } + + this._scriptsSource[scriptId] ||= this._cdp.debugger + .getScriptSource(this._sessionId, scriptId) + .then(res => res.scriptSource) + .catch((err: Error) => err); + + this._scriptsSourceMap[scriptId] ||= fetchTextWithBrowserFallback( + urlResolve(url, sourceMapURL), + this._cdp.runtime, + this._sessionId, + ); + } + + async start(): Promise { + const debuggerOnPaused = (this._debuggerOnPausedFn = async (): Promise => { + return this._cdp.debugger.resume(this._sessionId).catch(() => {}); + }); + + const debuggerOnScriptParsedFn = (this._debuggerOnScriptParsedFn = this._processScript.bind(this)); + const sessionId = this._sessionId; + + this._cdp.debugger.on("paused", debuggerOnPaused); + this._cdp.debugger.on("scriptParsed", debuggerOnScriptParsedFn); + + await Promise.all([ + this._cdp.target.setAutoAttach(sessionId, { autoAttach: true, waitForDebuggerOnStart: false }), + this._cdp.debugger.enable(sessionId), + this._cdp.profiler.enable(sessionId).then(() => + this._cdp.profiler.startPreciseCoverage(sessionId, { + callCount: false, + detailed: false, + allowTriggeredUpdates: false, + }), + ), + ]); + } + + /** @param drop only performs cleanup without providing actual deps. Should be "true" if test is failed */ + async stop(drop?: boolean): Promise { + if (drop) { + this._debuggerOnPausedFn && this._cdp.debugger.off("paused", this._debuggerOnPausedFn); + this._debuggerOnScriptParsedFn && this._cdp.debugger.off("scriptParsed", this._debuggerOnScriptParsedFn); + + return []; + } + + const coverage = await this._cdp.profiler.takePreciseCoverage(this._sessionId); + + const scriptsWithUrl = coverage.result.filter(script => script.url); + + // If we haven't got "scriptParsed" event for the script, pull up source code + source map manually + scriptsWithUrl.forEach(({ scriptId, url }) => { + if (Object.hasOwn(this._scriptsSource, scriptId) && Object.hasOwn(this._scriptsSourceMap, scriptId)) { + return; + } + + const scriptSourcePromise = this._cdp.debugger + .getScriptSource(this._sessionId, scriptId) + .then(res => res.scriptSource) + .catch((err: Error) => err); + + this._scriptsSource[scriptId] ||= scriptSourcePromise; + this._scriptsSourceMap[scriptId] ||= scriptSourcePromise.then(sourceCode => { + if (sourceCode instanceof Error) { + return sourceCode; + } + + const sourceMapsStartIndex = sourceCode.lastIndexOf(JS_SOURCE_MAP_URL_COMMENT); + const sourceMapsEndIndex = sourceCode.indexOf("\n", sourceMapsStartIndex); + + if (sourceMapsStartIndex === -1) { + return new Error("Source maping url comment is missing"); + } + + const sourceMapURL = + sourceMapsEndIndex === -1 + ? sourceCode.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length) + : sourceCode.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex); + + return fetchTextWithBrowserFallback(urlResolve(url, sourceMapURL), this._cdp.runtime, this._sessionId); + }); + }); + + const totalDependingSourceFiles = new Set(); + const grouppedByScriptCoverage = groupBy(coverage.result, "scriptId"); + const scriptIds = Object.keys(grouppedByScriptCoverage); + + await Promise.all( + scriptIds.map(async scriptId => { + // Every "scriptId" has only one uniq "url" + const url = grouppedByScriptCoverage[scriptId][0].url; + const [source, sourceMaps] = await Promise.all([ + this._scriptsSource[scriptId], + this._scriptsSourceMap[scriptId], + ]); + + if (!source || !sourceMaps) { + return; + } + + if (source instanceof Error) { + throw new Error(`JS Selectivity: Couldn't load source code at ${url}: ${source}`); + } + + if (sourceMaps instanceof Error) { + throw new Error(`JS Selectivity: Couldn't load source maps of ${url}: ${sourceMaps}`); + } + + const startOffsetsSet = new Set(); + + grouppedByScriptCoverage[scriptId].forEach(entry => { + entry.functions.forEach(fn => { + fn.ranges.forEach(range => { + startOffsetsSet.add(range.startOffset); + }); + }); + }); + + const dependingSourceFiles = await extractSourceFilesDeps( + source, + sourceMaps, + Array.from(startOffsetsSet), + this._sourceRoot, + ); + + for (const sourceFile of dependingSourceFiles.values()) { + if (isSourceCodeFile(sourceFile)) { + totalDependingSourceFiles.add(sourceFile); + } + } + }), + ); + + this._debuggerOnPausedFn && this._cdp.debugger.off("paused", this._debuggerOnPausedFn); + this._debuggerOnScriptParsedFn && this._cdp.debugger.off("scriptParsed", this._debuggerOnScriptParsedFn); + + return Array.from(totalDependingSourceFiles).sort(); + } +} diff --git a/src/browser/cdp/selectivity/test-dependencies-writer.ts b/src/browser/cdp/selectivity/test-dependencies-writer.ts new file mode 100644 index 000000000..172f0cc22 --- /dev/null +++ b/src/browser/cdp/selectivity/test-dependencies-writer.ts @@ -0,0 +1,72 @@ +import { memoize } from "lodash"; +import path from "node:path"; +import fs from "fs-extra"; +import { shallowSortObject } from "./utils"; +import type { Test } from "../../../types"; +import type { NormalizedDependencies } from "./types"; + +const areDepsSame = (browserDepsA?: NormalizedDependencies, browserDepsB?: NormalizedDependencies): boolean => { + const props: Array = ["js", "css", "modules"] as const; + + if (!browserDepsA || !browserDepsB) { + return false; + } + + for (const prop of props) { + if (!browserDepsA[prop] || !browserDepsB[prop] || browserDepsA[prop].length !== browserDepsB[prop].length) { + return false; + } + } + + // Rely on the fact both are sorted arrays + for (const prop of props) { + for (let i = 0; i < browserDepsA[prop].length; i++) { + if (browserDepsA[prop][i] !== browserDepsB[prop][i]) { + return false; + } + } + } + + return true; +}; + +export class TestDependenciesWriter { + private readonly _selectivityTestsPath: string; + private _directoryCreated = false; + + constructor(selectivityRootPath: string) { + this._selectivityTestsPath = path.join(selectivityRootPath, "tests"); + } + + async saveFor(test: Test, browserDependencies: NormalizedDependencies): Promise { + if (!this._directoryCreated) { + await fs.ensureDir(this._selectivityTestsPath); + this._directoryCreated = true; + } + + const testDepsPath = path.join(this._selectivityTestsPath, `${test.id}.json`); + const testDepsContent = fs.existsSync(testDepsPath) ? await fs.readFile(testDepsPath, "utf8") : ""; + let testDeps: Record = {}; + + try { + if (testDepsContent) { + testDeps = JSON.parse(testDepsContent); + } + } catch {} // eslint-disable-line no-empty + + if (areDepsSame(testDeps[test.browserId]?.browser, browserDependencies)) { + return; + } + + testDeps[test.browserId] = { browser: browserDependencies }; + + shallowSortObject(testDeps); + + // Writing pretty json to avoid vcs merge conflicts + await fs.writeFile(testDepsPath, JSON.stringify(testDeps, null, 2)); + } +} + +export const getTestDependenciesWriter = memoize((selectivityRootPath: string): TestDependenciesWriter => { + return new TestDependenciesWriter(selectivityRootPath); +}); diff --git a/src/browser/cdp/selectivity/types.ts b/src/browser/cdp/selectivity/types.ts new file mode 100644 index 000000000..ddde21b59 --- /dev/null +++ b/src/browser/cdp/selectivity/types.ts @@ -0,0 +1,8 @@ +export interface NormalizedDependencies { + /** Project-wide styles */ + css: string[]; + /** Project-wide scripts */ + js: string[]; + /** Module names from node_modules (e.g. "react", "@remix-run/router") */ + modules: string[]; +} diff --git a/src/browser/cdp/selectivity/utils.ts b/src/browser/cdp/selectivity/utils.ts new file mode 100644 index 000000000..26bc8381c --- /dev/null +++ b/src/browser/cdp/selectivity/utils.ts @@ -0,0 +1,234 @@ +import { sortedIndex, memoize } from "lodash"; +import { SourceMapConsumer, type BasicSourceMapConsumer, type RawSourceMap } from "source-map"; +import fs from "fs"; +import path from "path"; +import { URL } from "url"; +import type { CDPRuntime } from "../domains/runtime"; +import type { CDPSessionId } from "../types"; +import { softFileURLToPath } from "../../../utils/fs"; +import type { NormalizedDependencies } from "./types"; +import { WEBPACK_PROTOCOL } from "./constants"; + +/** + * Tries to fetch text by url from node.js, then falls back to "fetch" from browser, if node.js fetch fails + * @param url text url + * @param runtime CDP runtime domain + * @param sessionId CDP session id + * @returns text + */ +export const fetchTextWithBrowserFallback = async ( + url: string, + runtime: CDPRuntime, + sessionId: CDPSessionId, +): Promise => { + const isSourceMapEmbedded = new URL(url).protocol === "data:"; + + if (isSourceMapEmbedded) { + // With "data" protocol it just decodes embedded source maps without actual network requests + // So we can do it directly from node.js + return fetch(url) + .then(r => r.text()) + .catch((err: Error) => err); + } + + // At first, trying to fetch sourceMaps directly from the node.js + // Then falling back to do it via browser (if Testplane has no direct network access to it, for example) + try { + return await fetch(url).then(r => r.text()); + } catch { + return runtime + .evaluate(sessionId, { + expression: `fetch("${url.replaceAll('"', '\\"')}").then(r => r.text())`, + awaitPromise: true, + returnByValue: true, + }) + .then(r => r.result.value) + .catch((err: Error) => err); + } +}; + +const isWebpackProtocol = (posixRelativeSourceFilePath: string): boolean => { + return posixRelativeSourceFilePath.startsWith(WEBPACK_PROTOCOL); +}; + +/** + * Replaces "webpack://" protocol with source path because "source-map" doesn't work well with protocol paths + * @param sourceMap Raw source maps in https://tc39.es/ecma426/ format + * @param sourceRoot Source root + */ +export const patchSourceMapSources = (sourceMap: RawSourceMap, sourceRoot?: string): RawSourceMap => { + sourceMap.sourceRoot = sourceRoot || sourceMap.sourceRoot; + + for (let i = 0; i < sourceMap.sources.length; i++) { + if (isWebpackProtocol(sourceMap.sources[i])) { + sourceMap.sources[i] = sourceMap.sources[i].slice(WEBPACK_PROTOCOL.length); + } + } + + return sourceMap; +}; + +/** + * Given compiled code, its source map, and the executed offsets + * It returns the original source files touched + * Useful for turning coverage ranges into real TS/JS module dependencies + * @param source Compiled source code + * @param sourceMaps Source maps JSON string + * @param startOffsets Executed start offsets (v8 format) + * @param sourceRoot Source root + */ +export const extractSourceFilesDeps = async ( + source: string, + sourceMaps: string, + startOffsets: number[], + sourceRoot: string, +): Promise> => { + const dependantSourceFiles = new Set(); + const sourceMapsParsed = patchSourceMapSources(JSON.parse(sourceMaps), sourceRoot); + + const consumer = (await new SourceMapConsumer(sourceMapsParsed)) as BasicSourceMapConsumer; + + let sourceOffset = source.indexOf("\n"); + const offsetToLine = [0]; + + while (sourceOffset !== -1) { + offsetToLine.push(++sourceOffset); + sourceOffset = source.indexOf("\n", sourceOffset); + } + + for (const startOffset of startOffsets) { + let line = sortedIndex(offsetToLine, startOffset); + + if (startOffset < offsetToLine[line]) { + line--; + } + + const column = startOffset - offsetToLine[line]; + const position = consumer.originalPositionFor({ line: line + 1, column }); + + if (position.source) { + dependantSourceFiles.add(position.source); + } + } + + return dependantSourceFiles; +}; + +/** + * @returns True, if fileUrlLikePath has some kind of protocol ("data://", "webpack://", "turbopack://", "file://")... + */ +export const hasProtocol = (fileUrlLikePath: string): boolean => { + if (!fileUrlLikePath.includes("://")) { + return false; + } + + try { + return Boolean(new URL(fileUrlLikePath).protocol); + } catch { + return false; + } +}; + +const ensurePosixRelativeDependencyPathExists = memoize((posixRelativePath: string): void => { + const relativePath = posixRelativePath.replaceAll(path.posix.sep, path.sep); + + if (fs.existsSync(relativePath)) { + return; + } + + throw new Error(`Selectivity: Couldn;t find "${relativePath}", which is test's dependency`); +}); + +/** + * @param cssDependencies SORTED uniq array of css dependenciy URI's + * @param jsDependencies SORTED uniq array of js dependenciy URI's + * @returns sorted uniq arrays of relative paths + */ +export const transformSourceDependencies = ( + cssDependencies: string[], + jsDependencies: string[], +): NormalizedDependencies => { + const nodeModulesLabel = "node_modules/"; + const css: string[] = []; + const js: string[] = []; + const modules: string[] = []; + + const classifyDependency = (dependency: string, typedResultArray: string[]): void => { + dependency = decodeURIComponent(softFileURLToPath(dependency)); + + if (hasProtocol(dependency)) { + throw new Error(`Selectivity: Found unsupported protocol in dependencies ("${dependency}")`); + } + + const dependencyRelativePath = path.posix.relative(path.posix.resolve(), path.posix.resolve(dependency)); + + const nodeModulesLabelPos = dependencyRelativePath.indexOf(nodeModulesLabel); + + if (nodeModulesLabelPos === -1) { + ensurePosixRelativeDependencyPathExists(dependencyRelativePath); + typedResultArray.push(dependencyRelativePath); + return; + } + + const modulePos = nodeModulesLabelPos + nodeModulesLabel.length; + const isScopedDependency = dependencyRelativePath[modulePos] === "@"; + let moduleEndPos; + + if (isScopedDependency) { + const scopeEndPos = dependencyRelativePath.indexOf("/", modulePos + 1); + + moduleEndPos = scopeEndPos === -1 ? -1 : dependencyRelativePath.indexOf("/", scopeEndPos + 1); + } else { + moduleEndPos = dependencyRelativePath.indexOf("/", modulePos + 1); + } + + if (moduleEndPos === -1) { + ensurePosixRelativeDependencyPathExists(dependencyRelativePath); + typedResultArray.push(dependencyRelativePath); + } else { + const modulePath = dependencyRelativePath.slice(0, moduleEndPos); + + if (modules[modules.length - 1] !== modulePath) { + ensurePosixRelativeDependencyPathExists(modulePath); + + modules.push(modulePath); + } + } + }; + + let cssIndex = 0; + let jsIndex = 0; + + while (cssIndex < cssDependencies.length && jsIndex < jsDependencies.length) { + const compareResult = cssDependencies[cssIndex].localeCompare(jsDependencies[jsIndex]); + + if (compareResult < 0) { + classifyDependency(cssDependencies[cssIndex++], css); + } else { + classifyDependency(jsDependencies[jsIndex++], js); + } + } + + while (cssIndex < cssDependencies.length) { + classifyDependency(cssDependencies[cssIndex++], css); + } + + while (jsIndex < jsDependencies.length) { + classifyDependency(jsDependencies[jsIndex++], js); + } + + return { css, js, modules }; +}; + +// Ensures file consistency +export const shallowSortObject = (obj: Record): void => { + const testBrowsers = Object.keys(obj).sort(); + + for (const testBrowser of testBrowsers) { + const testBrowserDeps = obj[testBrowser]; + + delete obj[testBrowser]; + + obj[testBrowser] = testBrowserDeps; + } +}; diff --git a/src/browser/cdp/types.ts b/src/browser/cdp/types.ts index 77922ccb2..7b771134f 100644 --- a/src/browser/cdp/types.ts +++ b/src/browser/cdp/types.ts @@ -6,7 +6,27 @@ export type CDPRequestId = number; export type CDPTargetId = string; export type CDPSessionId = string; export type CDPBrowserContextId = string; -export type RuntimeScriptId = string; +export type CDPRuntimeScriptId = string; +export type CDPCallFrameId = string; +export type CDPExecutionContextId = number; +export type CDPStyleSheetId = string; +export type CDPFrameId = string; +export type CDPDOMBackendNodeId = number; +export type CDPStyleSheetOrigin = "injected" | "user-agent" | "inspector" | "regular"; +export type CDPDebuggerPausedReason = + | "ambiguous" + | "assert" + | "CSPViolation" + | "debugCommand" + | "DOM" + | "EventListener" + | "exception" + | "instrumentation" + | "OOM" + | "other" + | "promiseRejection" + | "XHR" + | "step"; // https://source.chromium.org/chromium/chromium/src/+/main:content/browser/devtools/devtools_agent_host_impl.cc;l=131-144?q=f:devtools%20-f:out%20%22::kTypeTab%5B%5D%22&ss=chromium type TargetType = @@ -84,7 +104,7 @@ interface FunctionCoverage { export interface CDPScriptCoverage { /** JavaScript script id. */ - scriptId: RuntimeScriptId; + scriptId: CDPRuntimeScriptId; /** JavaScript script name or url. */ url: string; /** Functions contained in the script that has coverage data. */ @@ -93,7 +113,7 @@ export interface CDPScriptCoverage { export interface CDPDebuggerLocation { /** Script identifier as reported in the Debugger.scriptParsed. */ - scriptId: RuntimeScriptId; + scriptId: CDPRuntimeScriptId; /** Line number in the script (0-based). */ lineNumber: number; /** Column number in the script (0-based). */ @@ -104,7 +124,7 @@ interface RuntimeCallFrame { /** JavaScript function name. */ functionName: string; /** JavaScript script id. */ - scriptId: RuntimeScriptId; + scriptId: CDPRuntimeScriptId; /** JavaScript script name or url. */ url: string; /** JavaScript script line number (0-based). */ @@ -149,3 +169,80 @@ export interface CDPProfile { */ timeDeltas: number[]; } + +interface DebuggerLocation { + scriptId: CDPRuntimeScriptId; + /** Line number in the script (0-based). */ + lineNumber: number; + /** Column number in the script (0-based). */ + columnNumber?: number; +} + +export interface CDPDebuggerCallFrame { + callFrameId: CDPCallFrameId; + functionName: string; + functionLocation?: DebuggerLocation; + location: DebuggerLocation; +} + +export interface CDPRuntimeStackTrace { + description?: string; + callFrames: CDPDebuggerCallFrame; + parent?: CDPRuntimeStackTrace; +} + +export interface CDPRuntimeRemoteObject { + /** Object type. */ + type: "object" | "function" | "undefined" | "string" | "number" | "boolean" | "symbol" | "bigint"; + /** Object subtype hint. Specified for object type values only. */ + subtype?: string; + /** Object class (constructor) name. Specified for object type values only. */ + className?: string; + /** Remote object value in case of primitive values or JSON values (if it was requested). */ + value: T; + /** Primitive value which can not be JSON-stringified does not have value, but gets this property. */ + unserializableValue?: string; + /** String representation of the object. */ + description?: string; +} + +export interface CDPCSSStyleSheetHeader { + /** The stylesheet identifier. */ + styleSheetId: CDPStyleSheetId; + /** Owner frame identifier. */ + frameId: CDPFrameId; + /** Stylesheet resource URL. Empty if this is a constructed stylesheet created using new CSSStyleSheet() (but non-empty if this is a constructed stylesheet imported as a CSS module script). */ + sourceURL: string; + /** URL of source map associated with the stylesheet (if any). */ + sourceMapURL?: string; + /** Stylesheet origin. */ + origin: CDPStyleSheetOrigin; + /** Stylesheet title. */ + title: string; + /** The backend id for the owner node of the stylesheet. */ + ownerNode?: CDPDOMBackendNodeId; + /** Denotes whether the stylesheet is disabled. */ + disabled: boolean; + /** Whether the sourceURL field value comes from the sourceURL comment. */ + hasSourceURL?: boolean; + /** Whether this stylesheet is created for STYLE tag by parser. This flag is not set for document.written STYLE tags. */ + isInline: boolean; + /** + * Whether this stylesheet is mutable. Inline stylesheets become mutable after they have been modified via CSSOM API. + * element's stylesheets become mutable only if DevTools modifies them. + * Constructed stylesheets (new CSSStyleSheet()) are mutable immediately after creation. + */ + isMutable: boolean; + /** True if this stylesheet is created through new CSSStyleSheet() or imported as a CSS module script. */ + isConstructed: boolean; + /** Line offset of the stylesheet within the resource (zero based). */ + startLine: number; + /** Column offset of the stylesheet within the resource (zero based). */ + startColumn: number; + /** Size of the content (in characters). */ + length: number; + /** Line offset of the end of the stylesheet within the resource (zero based). */ + endLine: number; + /** Column offset of the end of the stylesheet within the resource (zero based). */ + endColumn: number; +} diff --git a/src/browser/existing-browser.ts b/src/browser/existing-browser.ts index ea921b39f..e9752697a 100644 --- a/src/browser/existing-browser.ts +++ b/src/browser/existing-browser.ts @@ -538,4 +538,8 @@ export class ExistingBrowser extends Browser { get meta(): Record { return this._meta; } + + get cdp(): CDP | null { + return this._cdp; + } } diff --git a/src/config/browser-options.js b/src/config/browser-options.js index 8959a1288..474348c90 100644 --- a/src/config/browser-options.js +++ b/src/config/browser-options.js @@ -430,5 +430,13 @@ function buildBrowserOptions(defaultFactory, extra) { return value; }, }), + + selectivity: option({ + defaultValue: defaultFactory("selectivity"), + parseEnv: JSON.parse, + parseCli: JSON.parse, + validate: value => utils.assertOptionalObject(value, "selectivity"), + map: value => ({ ...defaults.selectivity, ...value }), + }), }); } diff --git a/src/config/defaults.js b/src/config/defaults.js index 4269a6879..8675377d4 100644 --- a/src/config/defaults.js +++ b/src/config/defaults.js @@ -122,6 +122,11 @@ module.exports = { }, passive: false, timeTravel: TimeTravelMode.Off, + selectivity: { + enabled: false, + sourceRoot: "", + testDependenciesPath: ".testplane/selectivity", + }, }; module.exports.configPaths = [ diff --git a/src/config/types.ts b/src/config/types.ts index 99911b616..37cc391df 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -367,6 +367,12 @@ export interface CommonConfig { reuseExisting: boolean; }; + selectivity: { + enabled: boolean; + sourceRoot: string; + testDependenciesPath: string; + }; + timeTravel: TimeTravelConfig; } @@ -385,7 +391,13 @@ export interface SetsConfigParsed { type PartialCommonConfig = Partial< Omit< CommonConfig, - "system" | "timeTravel" | "takeScreenshotOnFails" | "lastFailed" | "openAndWaitOpts" | "devServer" + | "system" + | "timeTravel" + | "takeScreenshotOnFails" + | "lastFailed" + | "openAndWaitOpts" + | "devServer" + | "selectivity" > > & { system?: Partial; @@ -394,6 +406,7 @@ type PartialCommonConfig = Partial< lastFailed?: Partial; openAndWaitOpts?: Partial; devServer?: Partial; + selectivity?: Partial; }; export type HookType = (params: { config: Config }) => Promise | undefined; diff --git a/src/error-snippets/constants.ts b/src/error-snippets/constants.ts index cbfd02fd4..673508006 100644 --- a/src/error-snippets/constants.ts +++ b/src/error-snippets/constants.ts @@ -1,4 +1,5 @@ -export const SOURCE_MAP_URL_COMMENT = "//# sourceMappingURL="; +export const JS_SOURCE_MAP_URL_COMMENT = "//# sourceMappingURL="; +export const CSS_SOURCE_MAP_URL_COMMENT = "/*# sourceMappingURL="; export const SOURCE_MAP_HEADER = "SourceMap"; export const SNIPPET_LINES_ABOVE = 2; export const SNIPPET_LINES_BELOW = 3; diff --git a/src/error-snippets/source-maps.ts b/src/error-snippets/source-maps.ts index 6063f8866..3ac3af455 100644 --- a/src/error-snippets/source-maps.ts +++ b/src/error-snippets/source-maps.ts @@ -1,6 +1,6 @@ import { SourceMapConsumer, type BasicSourceMapConsumer } from "source-map"; import url from "url"; -import { SOURCE_MAP_URL_COMMENT } from "./constants"; +import { JS_SOURCE_MAP_URL_COMMENT } from "./constants"; import { getSourceCodeFile } from "./utils"; import { softFileURLToPath } from "../utils/fs"; import { transformCode } from "../utils/typescript"; @@ -10,11 +10,11 @@ export const extractSourceMaps = async ( fileContents: string, fileName: string, ): Promise => { - if (fileContents.indexOf(SOURCE_MAP_URL_COMMENT) === -1) { + if (fileContents.indexOf(JS_SOURCE_MAP_URL_COMMENT) === -1) { fileContents = transformCode(fileContents, { sourceFile: fileName, sourceMaps: true }); } - const sourceMapsStartIndex = fileContents.indexOf(SOURCE_MAP_URL_COMMENT); + const sourceMapsStartIndex = fileContents.lastIndexOf(JS_SOURCE_MAP_URL_COMMENT); const sourceMapsEndIndex = fileContents.indexOf("\n", sourceMapsStartIndex); if (sourceMapsStartIndex === -1) { @@ -23,8 +23,8 @@ export const extractSourceMaps = async ( const sourceMapUrl = sourceMapsEndIndex === -1 - ? fileContents.slice(sourceMapsStartIndex + SOURCE_MAP_URL_COMMENT.length) - : fileContents.slice(sourceMapsStartIndex + SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex); + ? fileContents.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length) + : fileContents.slice(sourceMapsStartIndex + JS_SOURCE_MAP_URL_COMMENT.length, sourceMapsEndIndex); const sourceMaps = await getSourceCodeFile(url.resolve(fileName, sourceMapUrl)); const consumer = (await new SourceMapConsumer(sourceMaps)) as BasicSourceMapConsumer; diff --git a/src/error-snippets/utils.ts b/src/error-snippets/utils.ts index 2f9cfd0e4..280bf25b5 100644 --- a/src/error-snippets/utils.ts +++ b/src/error-snippets/utils.ts @@ -2,7 +2,7 @@ import path from "path"; import fs from "fs-extra"; import { codeFrameColumns } from "@babel/code-frame"; import { getErrorTitle } from "../browser/stacktrace/utils"; -import { SNIPPET_LINES_ABOVE, SNIPPET_LINES_BELOW, SOURCE_MAP_URL_COMMENT } from "./constants"; +import { SNIPPET_LINES_ABOVE, SNIPPET_LINES_BELOW, JS_SOURCE_MAP_URL_COMMENT } from "./constants"; import { AssertViewError } from "../browser/commands/assert-view/errors/assert-view-error"; import { BaseStateError } from "../browser/commands/assert-view/errors/base-state-error"; import { softFileURLToPath } from "../utils/fs"; @@ -80,11 +80,11 @@ export const getSourceCodeFile = async (fileName: string): Promise => { const response = await fetch(filePath); const responseText = await response.text(); - if (responseText.includes(SOURCE_MAP_URL_COMMENT) || !response.headers.has("SourceMap")) { + if (responseText.includes(JS_SOURCE_MAP_URL_COMMENT) || !response.headers.has("SourceMap")) { return responseText; } const sourceMapUrl = response.headers.get("SourceMap"); - return responseText + "\n" + SOURCE_MAP_URL_COMMENT + sourceMapUrl; + return responseText + "\n" + JS_SOURCE_MAP_URL_COMMENT + sourceMapUrl; }; diff --git a/src/worker/runner/test-runner/index.js b/src/worker/runner/test-runner/index.js index 097813675..e944be016 100644 --- a/src/worker/runner/test-runner/index.js +++ b/src/worker/runner/test-runner/index.js @@ -11,6 +11,7 @@ const { SAVE_HISTORY_MODE } = require("../../../constants/config"); const { filterExtraStackFrames } = require("../../../browser/stacktrace/utils"); const { extendWithCodeSnippet } = require("../../../error-snippets"); const { TestplaneInternalError } = require("../../../errors"); +const { startSelectivity } = require("../../../browser/cdp/selectivity"); module.exports = class TestRunner { static create(...args) { @@ -51,8 +52,12 @@ module.exports = class TestRunner { async run() { await this.prepareToRun(); + const stopSelectivity = await startSelectivity(this._browser); + const error = await this.runRunnables(ExecutionThread); + await stopSelectivity(this._test, Boolean(error)); + return this.finishRun(error); } diff --git a/test/src/browser/cdp/connection.ts b/test/src/browser/cdp/connection.ts index 367bae579..77115c067 100644 --- a/test/src/browser/cdp/connection.ts +++ b/test/src/browser/cdp/connection.ts @@ -169,7 +169,7 @@ describe('"CDPConnection"', () => { }); it("should send request and receive successful response", async () => { - const response = await connection.request("Successful.Method", { params: { test: "value" } }); + const response = await connection.request("Successful.Method", { params: { test: "value" } }); assert.deepEqual(response, { id: 1, params: { test: "value" } }); }); @@ -183,8 +183,8 @@ describe('"CDPConnection"', () => { }); it("should generate unique request IDs", async () => { - const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); - const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); + const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); + const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); const [result1, result2] = await Promise.all([promise1, promise2]); @@ -197,8 +197,8 @@ describe('"CDPConnection"', () => { // Set the connection's request ID to near maximum Object.defineProperty(connection, "requestId", { value: CDP_MAX_REQUEST_ID - 1 }); - const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); - const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); + const promise1 = connection.request("Successful.Method", { params: { test: "1" } }); + const promise2 = connection.request("Successful.Method", { params: { test: "2" } }); const [result1, result2] = await Promise.all([promise1, promise2]); @@ -208,7 +208,7 @@ describe('"CDPConnection"', () => { }); it("should handle request with sessionId", async () => { - const result = await connection.request("Successful.Method", { + const result = await connection.request("Successful.Method", { sessionId: "custom-session-id", params: { test: "value" }, }); @@ -235,7 +235,7 @@ describe('"CDPConnection"', () => { }); it("should retry requests on retryable errors", async () => { - const response = await connection.request("Flaky.Method", { + const response = await connection.request("Flaky.Method", { params: { respondAfter: 3, errorCode: -1000 }, }); @@ -278,7 +278,7 @@ describe('"CDPConnection"', () => { it("should establish connection successfully", async () => { // Make a request to verify connection is established - const result = await connection.request("Successful.Method", { params: { test: "value" } }); + const result = await connection.request("Successful.Method", { params: { test: "value" } }); assert.deepEqual(result, { id: 1, params: { test: "value" } }); assert.calledWith(getWsEndpointStub, mockBrowser); @@ -300,9 +300,9 @@ describe('"CDPConnection"', () => { it("should connect once on multiple requests", async () => { const [r1, r2, r3] = await Promise.all([ - connection.request("Successful.Method"), - connection.request("Successful.Method"), - connection.request("Successful.Method"), + connection.request("Successful.Method"), + connection.request("Successful.Method"), + connection.request("Successful.Method"), ]); assert.deepEqual(r1, { id: 1 }); @@ -362,7 +362,7 @@ describe('"CDPConnection"', () => { getWsServerConnection().send("invalid json"); // Make a request to ensure connection is still working - const result = await connection.request("Successful.Method", { params: {} }); + const result = await connection.request("Successful.Method", { params: {} }); // Should handle malformed JSON gracefully and still process valid requests assert.deepEqual(result, { id: 1, params: {} }); @@ -397,7 +397,7 @@ describe('"CDPConnection"', () => { serverWs.send(JSON.stringify({ id: 999, result: {} })); // Make a normal request - should work despite the unknown response - const result = await connection.request("Successful.Method", { params: { test: "value" } }); + const result = await connection.request("Successful.Method", { params: { test: "value" } }); assert.deepEqual(result, { id: 1, params: { test: "value" } }); }); }); @@ -437,8 +437,8 @@ describe('"CDPConnection"', () => { }); it("should reuse existing connection for multiple requests", async () => { - const promise1 = connection.request("Successful.Method"); - const promise2 = connection.request("Successful.Method"); + const promise1 = connection.request("Successful.Method"); + const promise2 = connection.request("Successful.Method"); const [result1, result2] = await Promise.all([promise1, promise2]); @@ -447,19 +447,19 @@ describe('"CDPConnection"', () => { }); it("should handle connection drop and reconnect", async () => { - const result1 = await connection.request("Successful.Method"); + const result1 = await connection.request("Successful.Method"); assert.deepEqual(result1, { id: 1 }); // Simulate connection drop getWsServerConnection().close(); // Make second request (should trigger reconnection) - const result2 = await connection.request("Successful.Method"); + const result2 = await connection.request("Successful.Method"); assert.deepEqual(result2, { id: 3 }); }); it("should handle connection termination and reconnect", async () => { - const result1 = await connection.request("Successful.Method"); + const result1 = await connection.request("Successful.Method"); assert.deepEqual(result1, { id: 1 }); // Simulate connection error @@ -467,7 +467,7 @@ describe('"CDPConnection"', () => { // Make request (should trigger reconnection) // Request is retried because of termination - const result2 = await connection.request("Successful.Method"); + const result2 = await connection.request("Successful.Method"); assert.deepEqual(result2, { id: 3 }); assert.calledOnce(exponentiallyWaitStub); }); diff --git a/test/src/browser/cdp/selectivity/css-selectivity.ts b/test/src/browser/cdp/selectivity/css-selectivity.ts new file mode 100644 index 000000000..0e515393d --- /dev/null +++ b/test/src/browser/cdp/selectivity/css-selectivity.ts @@ -0,0 +1,491 @@ +import sinon, { SinonStub } from "sinon"; +import proxyquire from "proxyquire"; + +describe("CDP/Selectivity/CSSSelectivity", () => { + const sandbox = sinon.createSandbox(); + let CSSSelectivity: typeof import("src/browser/cdp/selectivity/css-selectivity").CSSSelectivity; + let cdpMock: { + target: { setAutoAttach: SinonStub }; + dom: { enable: SinonStub }; + css: { + enable: SinonStub; + startRuleUsageTracking: SinonStub; + stopRuleUsageTracking: SinonStub; + getStyleSheetText: SinonStub; + on: SinonStub; + off: SinonStub; + }; + runtime: any; + }; + let fetchTextWithBrowserFallbackStub: SinonStub; + let patchSourceMapSourcesStub: SinonStub; + let urlResolveStub: SinonStub; + let groupByStub: SinonStub; + let pathStub: { posix: { join: SinonStub } }; + let hasProtocolStub: SinonStub; + + const sessionId = "test-session-id"; + const sourceRoot = "/test/source-root"; + + beforeEach(() => { + cdpMock = { + target: { setAutoAttach: sandbox.stub().resolves() }, + dom: { enable: sandbox.stub().resolves() }, + css: { + enable: sandbox.stub().resolves(), + startRuleUsageTracking: sandbox.stub().resolves(), + stopRuleUsageTracking: sandbox.stub().resolves({ + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }), + getStyleSheetText: sandbox.stub().resolves({ text: "mock css" }), + on: sandbox.stub(), + off: sandbox.stub(), + }, + runtime: {}, + }; + + fetchTextWithBrowserFallbackStub = sandbox.stub().resolves( + JSON.stringify({ + version: 3, + sources: ["src/styles.css", "src/theme.css"], + sourceRoot: "/root", + names: [], + mappings: "", + file: "styles.css", + }), + ); + patchSourceMapSourcesStub = sandbox.stub().returnsArg(0); + urlResolveStub = sandbox.stub().returnsArg(1); + groupByStub = sandbox.stub().callsFake((arr, key) => { + const result: Record = {}; + arr.forEach((item: any) => { + const keyValue = item[key]; + if (!result[keyValue]) result[keyValue] = []; + result[keyValue].push(item); + }); + return result; + }); + pathStub = { + posix: { join: sandbox.stub().callsFake((...args) => args.join("/")) }, + }; + hasProtocolStub = sandbox.stub().returns(false); + + CSSSelectivity = proxyquire("src/browser/cdp/selectivity/css-selectivity", { + lodash: { groupBy: groupByStub }, + path: pathStub, + "node:url": { resolve: urlResolveStub, URL: global.URL }, + "./utils": { + fetchTextWithBrowserFallback: fetchTextWithBrowserFallbackStub, + patchSourceMapSources: patchSourceMapSourcesStub, + hasProtocol: hasProtocolStub, + }, + }).CSSSelectivity; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("constructor", () => { + it("should initialize with correct parameters", () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + assert.isOk(cssSelectivity); + }); + + it("should initialize with default sourceRoot", () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId); + + assert.isOk(cssSelectivity); + }); + }); + + describe("start", () => { + it("should set up CDP connections and start rule usage tracking", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + + assert.calledWith(cdpMock.target.setAutoAttach, sessionId, { + autoAttach: true, + waitForDebuggerOnStart: false, + }); + assert.calledWith(cdpMock.dom.enable, sessionId); + assert.calledWith(cdpMock.css.enable, sessionId); + assert.calledWith(cdpMock.css.startRuleUsageTracking, sessionId); + assert.calledOnceWith(cdpMock.css.on, "styleSheetAdded"); + }); + + it("should handle styleSheetAdded events", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + const styleSheetEvent = { + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, + }; + + styleSheetAddedHandler(styleSheetEvent); + + assert.calledWith(fetchTextWithBrowserFallbackStub, "styles.css.map", cdpMock.runtime, sessionId); + }); + + it("should handle styleSheetAdded events without sourceURL or sourceMapURL", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + const styleSheetEvent = { + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "", + sourceMapURL: "", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, + }; + + styleSheetAddedHandler(styleSheetEvent); + + assert.notCalled(fetchTextWithBrowserFallbackStub); + }); + + it("should not process events if sessionId is not set", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, "", sourceRoot); + + await cssSelectivity.start(); + + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + const styleSheetEvent = { + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, + }; + + styleSheetAddedHandler(styleSheetEvent); + + assert.notCalled(fetchTextWithBrowserFallbackStub); + }); + }); + + describe("stop", () => { + it("should return empty array when drop is true", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + await cssSelectivity.start(); + const result = await cssSelectivity.stop(true); + + assert.deepEqual(result, []); + assert.calledOnceWith(cdpMock.css.off, "styleSheetAdded"); + }); + + it("should process rule usage and return dependencies", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const mockSourceMap = { + sources: ["src/styles.css", "src/theme.css"], + sourceRoot: "/root", + }; + + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + patchSourceMapSourcesStub.returns(mockSourceMap); + + await cssSelectivity.start(); + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + styleSheetAddedHandler({ + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, + }); + + const result = await cssSelectivity.stop(); + + assert.calledWith(cdpMock.css.stopRuleUsageTracking, sessionId); + assert.calledWith( + patchSourceMapSourcesStub, + { + version: 3, + sources: ["src/styles.css", "src/theme.css"], + sourceRoot: "/root", + names: [], + mappings: "", + file: "styles.css", + }, + sourceRoot, + ); + + assert.deepEqual(result, ["/root/src/styles.css", "/root/src/theme.css"]); + }); + + it("should handle missing styleSheetAdded events by fetching manually", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const cssWithSourceMap = `.test { color: red; } +/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==*/`; + + const mockSourceMap = { + sources: ["src/styles.css"], + sourceRoot: "/root", + }; + + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + cdpMock.css.getStyleSheetText.resolves({ text: cssWithSourceMap }); + patchSourceMapSourcesStub.returns(mockSourceMap); + + await cssSelectivity.start(); + await cssSelectivity.stop(); + + assert.calledWith(cdpMock.css.getStyleSheetText, sessionId, "stylesheet-123"); + assert.calledWith( + fetchTextWithBrowserFallbackStub, + "data:application/json;base64,eyJ2ZXJzaW9uIjozfQ==", + cdpMock.runtime, + sessionId, + ); + }); + + it("should handle CSS without source map comment", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const cssWithoutSourceMap = ".test { color: red; }"; + + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + cdpMock.css.getStyleSheetText.resolves({ text: cssWithoutSourceMap }); + + await cssSelectivity.start(); + + await assert.isRejected( + cssSelectivity.stop(), + /CSS Selectivity: Couldn't load source maps for stylesheet id stylesheet-123.*Source maping url comment is missing/, + ); + }); + + it("should handle non-embedded source maps without stylesheet URL", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const cssWithExternalSourceMap = `.test { color: red; } +/*# sourceMappingURL=http://example.com/styles.css.map*/`; + + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + cdpMock.css.getStyleSheetText.resolves({ text: cssWithExternalSourceMap }); + + await cssSelectivity.start(); + + await assert.isRejected( + cssSelectivity.stop(), + /CSS Selectivity: Couldn't load source maps for stylesheet id stylesheet-123.*Missed stylesheet url for stylesheet id stylesheet-123/, + ); + }); + + it("should handle stylesheet text fetch errors", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const fetchError = new Error("Failed to fetch stylesheet"); + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + cdpMock.css.getStyleSheetText.rejects(fetchError); + + await cssSelectivity.start(); + + await assert.isRejected( + cssSelectivity.stop(), + /CSS Selectivity: Couldn't load source maps for stylesheet id stylesheet-123.*Failed to fetch stylesheet/, + ); + }); + + it("should handle source map fetch errors", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123", startOffset: 0, endOffset: 100, used: true }], + }; + + const sourceMapError = new Error("Failed to fetch source map"); + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + fetchTextWithBrowserFallbackStub.resolves(sourceMapError); + + await cssSelectivity.start(); + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + styleSheetAddedHandler({ + header: { + styleSheetId: "stylesheet-123", + frameId: "frame-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + origin: "regular" as const, + title: "styles.css", + disabled: false, + isInline: false, + isMutable: false, + isConstructed: false, + startLine: 0, + startColumn: 0, + length: 100, + endLine: 10, + endColumn: 0, + }, + }); + + await assert.isRejected( + cssSelectivity.stop(), + /CSS Selectivity: Couldn't load source maps for stylesheet id stylesheet-123.*Failed to fetch source map/, + ); + }); + + it("should handle files with protocols", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123" }], + }; + + const mockSourceMap = { + sources: ["file:///absolute/path/styles.css", "src/relative.css"], + sourceRoot: "/root", + }; + + hasProtocolStub.callsFake((path: string) => path.startsWith("file://")); + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + patchSourceMapSourcesStub.returns(mockSourceMap); + + await cssSelectivity.start(); + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + styleSheetAddedHandler({ + header: { + styleSheetId: "stylesheet-123", + sourceURL: "http://example.com/styles.css", + sourceMapURL: "styles.css.map", + }, + }); + + const result = await cssSelectivity.stop(); + + assert.deepEqual(result, ["/root/src/relative.css", "file:///absolute/path/styles.css"]); + }); + + it("should return sorted and unique dependencies", async () => { + const cssSelectivity = new CSSSelectivity(cdpMock as any, sessionId, sourceRoot); + + const mockRuleUsage = { + ruleUsage: [{ styleSheetId: "stylesheet-123" }, { styleSheetId: "stylesheet-456" }], + }; + + const mockSourceMap1 = { + sources: ["src/z-styles.css", "src/a-styles.css"], + sourceRoot: "/root", + }; + + const mockSourceMap2 = { + sources: ["src/b-styles.css", "src/a-styles.css"], + sourceRoot: "/root", + }; + + cdpMock.css.stopRuleUsageTracking.resolves(mockRuleUsage); + patchSourceMapSourcesStub.onFirstCall().returns(mockSourceMap1).onSecondCall().returns(mockSourceMap2); + + await cssSelectivity.start(); + const styleSheetAddedHandler = cdpMock.css.on.getCall(0).args[1]; + + styleSheetAddedHandler({ + header: { + styleSheetId: "stylesheet-123", + sourceURL: "http://example.com/styles1.css", + sourceMapURL: "styles1.css.map", + }, + }); + + styleSheetAddedHandler({ + header: { + styleSheetId: "stylesheet-456", + sourceURL: "http://example.com/styles2.css", + sourceMapURL: "styles2.css.map", + }, + }); + + const result = await cssSelectivity.stop(); + + assert.deepEqual(result, ["/root/src/a-styles.css", "/root/src/b-styles.css", "/root/src/z-styles.css"]); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/file-hash-provider.ts b/test/src/browser/cdp/selectivity/file-hash-provider.ts new file mode 100644 index 000000000..49adf60da --- /dev/null +++ b/test/src/browser/cdp/selectivity/file-hash-provider.ts @@ -0,0 +1,187 @@ +import sinon, { SinonStub } from "sinon"; +import proxyquire from "proxyquire"; +import { EventEmitter } from "events"; + +describe("CDP/Selectivity/FileHashProvider", () => { + const sandbox = sinon.createSandbox(); + let FileHashProvider: typeof import("src/browser/cdp/selectivity/file-hash-provider").FileHashProvider; + let cryptoStub: { createHash: SinonStub }; + let fsStub: { createReadStream: SinonStub }; + let hashMock: { update: SinonStub; digest: SinonStub }; + let streamMock: EventEmitter; + + beforeEach(() => { + hashMock = { + update: sandbox.stub(), + digest: sandbox.stub().returns("mocked-hash-value"), + }; + cryptoStub = { + createHash: sandbox.stub().returns(hashMock), + }; + streamMock = new EventEmitter(); + fsStub = { + createReadStream: sandbox.stub().returns(streamMock), + }; + + FileHashProvider = proxyquire("src/browser/cdp/selectivity/file-hash-provider", { + "node:crypto": cryptoStub, + "node:fs": fsStub, + }).FileHashProvider; + }); + + afterEach(() => { + sandbox.restore(); + // Clear the static hash store between tests + (FileHashProvider as any)._hashStore?.clear(); + }); + + describe("calculateFor", () => { + it("should calculate hash for a file", async () => { + const filePath = "/path/to/file.js"; + const provider = new FileHashProvider(); + + const hashPromise = provider.calculateFor(filePath); + + streamMock.emit("data", Buffer.from("chunk1")); + streamMock.emit("data", Buffer.from("chunk2")); + streamMock.emit("end"); + + const result = await hashPromise; + + assert.equal(result, "mocked-hash-value"); + assert.calledOnceWith(cryptoStub.createHash, "md5"); + assert.calledOnceWith(fsStub.createReadStream, filePath); + assert.calledTwice(hashMock.update); + assert.calledWith(hashMock.update.firstCall, Buffer.from("chunk1")); + assert.calledWith(hashMock.update.secondCall, Buffer.from("chunk2")); + assert.calledOnceWith(hashMock.digest, "hex"); + }); + + it("should return cached hash for the same file", async () => { + const filePath = "/path/to/file.js"; + const provider = new FileHashProvider(); + + // First call + const firstPromise = provider.calculateFor(filePath); + streamMock.emit("data", Buffer.from("data")); + streamMock.emit("end"); + const firstResult = await firstPromise; + + // Second call + const secondResult = await provider.calculateFor(filePath); + + assert.equal(firstResult, "mocked-hash-value"); + assert.equal(secondResult, "mocked-hash-value"); + assert.calledOnce(fsStub.createReadStream); + }); + + it("should handle file read errors", async () => { + const filePath = "/path/to/nonexistent.js"; + const provider = new FileHashProvider(); + const error = new Error("ENOENT: no such file or directory"); + + const hashPromise = provider.calculateFor(filePath); + + streamMock.emit("error", error); + + await assert.isRejected( + hashPromise, + /Selectivity: Couldn't calculate hash for \/path\/to\/nonexistent\.js: Error: ENOENT: no such file or directory/, + ); + }); + + it("should handle multiple concurrent requests for the same file", async () => { + const filePath = "/path/to/file.js"; + const provider = new FileHashProvider(); + + const promise1 = provider.calculateFor(filePath); + const promise2 = provider.calculateFor(filePath); + const promise3 = provider.calculateFor(filePath); + + streamMock.emit("data", Buffer.from("data")); + streamMock.emit("end"); + + const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]); + + assert.equal(result1, "mocked-hash-value"); + assert.equal(result2, "mocked-hash-value"); + assert.equal(result3, "mocked-hash-value"); + assert.calledOnce(fsStub.createReadStream); + }); + + it("should handle different files separately", async () => { + const filePath1 = "/path/to/file1.js"; + const filePath2 = "/path/to/file2.js"; + const provider = new FileHashProvider(); + + const streamMock1 = new EventEmitter(); + const streamMock2 = new EventEmitter(); + + fsStub.createReadStream.callsFake((path: string) => { + if (path === filePath1) { + return streamMock1; + } else if (path === filePath2) { + return streamMock2; + } + return new EventEmitter(); + }); + + hashMock.digest.onFirstCall().returns("hash1").onSecondCall().returns("hash2"); + + const promise1 = provider.calculateFor(filePath1); + const promise2 = provider.calculateFor(filePath2); + + streamMock1.emit("data", Buffer.from("data1")); + streamMock1.emit("end"); + streamMock2.emit("data", Buffer.from("data2")); + streamMock2.emit("end"); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.equal(result1, "hash1"); + assert.equal(result2, "hash2"); + assert.calledTwice(fsStub.createReadStream); + assert.calledWith(fsStub.createReadStream.firstCall, filePath1); + assert.calledWith(fsStub.createReadStream.secondCall, filePath2); + }); + + it("should create new hash instance for each file", async () => { + const filePath1 = "/path/to/file1.js"; + const filePath2 = "/path/to/file2.js"; + const provider = new FileHashProvider(); + + const hash1Mock = { update: sandbox.stub(), digest: sandbox.stub().returns("hash1") }; + const hash2Mock = { update: sandbox.stub(), digest: sandbox.stub().returns("hash2") }; + + cryptoStub.createHash.onFirstCall().returns(hash1Mock).onSecondCall().returns(hash2Mock); + + const streamMock1 = new EventEmitter(); + const streamMock2 = new EventEmitter(); + + fsStub.createReadStream.callsFake((path: string) => { + if (path === filePath1) { + return streamMock1; + } else if (path === filePath2) { + return streamMock2; + } + return new EventEmitter(); + }); + + const promise1 = provider.calculateFor(filePath1); + const promise2 = provider.calculateFor(filePath2); + + streamMock1.emit("data", Buffer.from("data1")); + streamMock1.emit("end"); + streamMock2.emit("data", Buffer.from("data2")); + streamMock2.emit("end"); + + const [result1, result2] = await Promise.all([promise1, promise2]); + + assert.equal(result1, "hash1"); + assert.equal(result2, "hash2"); + assert.calledTwice(cryptoStub.createHash); + assert.calledWith(hash1Mock.update, Buffer.from("data1")); + assert.calledWith(hash2Mock.update, Buffer.from("data2")); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/file-hash-writer.ts b/test/src/browser/cdp/selectivity/file-hash-writer.ts new file mode 100644 index 000000000..48a1e2d41 --- /dev/null +++ b/test/src/browser/cdp/selectivity/file-hash-writer.ts @@ -0,0 +1,309 @@ +import sinon, { SinonStub } from "sinon"; +import proxyquire from "proxyquire"; + +describe("CDP/Selectivity/FileHashWriter", () => { + const sandbox = sinon.createSandbox(); + let FileHashWriter: any; + let getFileHashWriter: typeof import("src/browser/cdp/selectivity/file-hash-writer").getFileHashWriter; + let FileHashProviderStub: SinonStub; + let fsExtraStub: { exists: SinonStub; readFile: SinonStub; outputJSON: SinonStub }; + let pathStub: { join: SinonStub }; + let shallowSortObjectStub: SinonStub; + let fileHashProviderMock: { calculateFor: SinonStub }; + + beforeEach(() => { + fileHashProviderMock = { calculateFor: sandbox.stub() }; + FileHashProviderStub = sandbox.stub().returns(fileHashProviderMock); + fsExtraStub = { + exists: sandbox.stub().resolves(false), + readFile: sandbox.stub().resolves("{}"), + outputJSON: sandbox.stub().resolves(), + }; + pathStub = { + join: sandbox.stub().callsFake((...args) => args.join("/")), + }; + shallowSortObjectStub = sandbox.stub(); + + const proxyquiredModule = proxyquire("src/browser/cdp/selectivity/file-hash-writer", { + "node:path": pathStub, + "fs-extra": fsExtraStub, + "./file-hash-provider": { FileHashProvider: FileHashProviderStub }, + "./utils": { shallowSortObject: shallowSortObjectStub }, + }); + + FileHashWriter = proxyquiredModule.FileHashWriter || proxyquiredModule.default; + getFileHashWriter = proxyquiredModule.getFileHashWriter; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("constructor", () => { + it("should initialize with correct paths", () => { + const selectivityRootPath = "/test/selectivity"; + new FileHashWriter(selectivityRootPath); + + assert.calledWith(pathStub.join, selectivityRootPath, "hashes.json"); + }); + }); + + describe("add", () => { + it("should add file and module dependencies", () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css", "src/theme.css"], + js: ["src/app.js", "src/utils.js"], + modules: ["node_modules/react", "node_modules/lodash"], + }; + + fileHashProviderMock.calculateFor.returns(Promise.resolve("hash123")); + + writer.add(dependencies); + + assert.calledWith(fileHashProviderMock.calculateFor, "src/styles.css"); + assert.calledWith(fileHashProviderMock.calculateFor, "src/theme.css"); + assert.calledWith(fileHashProviderMock.calculateFor, "src/app.js"); + assert.calledWith(fileHashProviderMock.calculateFor, "src/utils.js"); + assert.calledWith(fileHashProviderMock.calculateFor, "node_modules/react/package.json"); + assert.calledWith(fileHashProviderMock.calculateFor, "node_modules/lodash/package.json"); + }); + + it("should not add duplicate dependencies", () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }; + + fileHashProviderMock.calculateFor.returns(Promise.resolve("hash123")); + + writer.add(dependencies); + writer.add(dependencies); + + assert.calledOnce(fileHashProviderMock.calculateFor.withArgs("src/styles.css")); + assert.calledOnce(fileHashProviderMock.calculateFor.withArgs("src/app.js")); + assert.calledOnce(fileHashProviderMock.calculateFor.withArgs("node_modules/react/package.json")); + }); + + it("should handle empty dependencies", () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: [], + js: [], + modules: [], + }; + + fileHashProviderMock.calculateFor.returns(Promise.resolve("hash123")); + + writer.add(dependencies); + + assert.notCalled(fileHashProviderMock.calculateFor); + }); + }); + + describe("commit", () => { + it("should not commit if not initialized", async () => { + const writer = new FileHashWriter("/test/selectivity"); + + await writer.commit(); + + assert.notCalled(fsExtraStub.outputJSON); + }); + + it("should not commit if no staged dependencies", async () => { + const writer = new FileHashWriter("/test/selectivity"); + + fsExtraStub.exists.resolves(false); + writer.add({ css: [], js: [], modules: [] }); + + await writer.commit(); + + assert.notCalled(fsExtraStub.outputJSON); + }); + + it("should create new hash file if it doesn't exist", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }; + + fsExtraStub.exists.resolves(false); + fileHashProviderMock.calculateFor + .withArgs("src/styles.css") + .resolves("css-hash") + .withArgs("src/app.js") + .resolves("js-hash") + .withArgs("node_modules/react/package.json") + .resolves("module-hash"); + + writer.add(dependencies); + await writer.commit(); + + assert.calledWith( + fsExtraStub.outputJSON, + "/test/selectivity/hashes.json", + { + files: { + "src/styles.css": "css-hash", + "src/app.js": "js-hash", + }, + modules: { + "node_modules/react": "module-hash", + }, + }, + { spaces: 2 }, + ); + }); + + it("should update existing hash file", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/new-styles.css"], + js: ["src/new-app.js"], + modules: ["node_modules/new-lib"], + }; + + const existingContent = JSON.stringify({ + files: { "src/old-file.js": "old-hash" }, + modules: { "node_modules/old-lib": "old-module-hash" }, + }); + + fsExtraStub.exists.resolves(true); + fsExtraStub.readFile.resolves(existingContent); + fileHashProviderMock.calculateFor + .withArgs("src/new-styles.css") + .resolves("new-css-hash") + .withArgs("src/new-app.js") + .resolves("new-js-hash") + .withArgs("node_modules/new-lib/package.json") + .resolves("new-module-hash"); + + writer.add(dependencies); + await writer.commit(); + + assert.calledWith( + fsExtraStub.outputJSON, + "/test/selectivity/hashes.json", + { + files: { + "src/old-file.js": "old-hash", + "src/new-styles.css": "new-css-hash", + "src/new-app.js": "new-js-hash", + }, + modules: { + "node_modules/old-lib": "old-module-hash", + "node_modules/new-lib": "new-module-hash", + }, + }, + { spaces: 2 }, + ); + }); + + it("should not update files with same hash", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: [], + modules: [], + }; + + const existingContent = JSON.stringify({ + files: { "src/styles.css": "same-hash" }, + modules: {}, + }); + + fsExtraStub.exists.resolves(true); + fsExtraStub.readFile.resolves(existingContent); + fileHashProviderMock.calculateFor.withArgs("src/styles.css").resolves("same-hash"); + + writer.add(dependencies); + await writer.commit(); + + // Should not write to file since hash is the same + assert.notCalled(fsExtraStub.outputJSON); + }); + + it("should handle hash calculation errors", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: [], + modules: [], + }; + + const error = new Error("File not found"); + fsExtraStub.exists.resolves(false); + fileHashProviderMock.calculateFor.withArgs("src/styles.css").rejects(error); + + writer.add(dependencies); + + await assert.isRejected(writer.commit(), "File not found"); + }); + + it("should handle corrupted hash file", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: [], + modules: [], + }; + + fsExtraStub.exists.resolves(true); + fsExtraStub.readFile.resolves("invalid json"); + fileHashProviderMock.calculateFor.withArgs("src/styles.css").resolves("new-hash"); + + writer.add(dependencies); + await writer.commit(); + + // Should create new file structure when JSON parsing fails + assert.calledWith( + fsExtraStub.outputJSON, + "/test/selectivity/hashes.json", + { + files: { "src/styles.css": "new-hash" }, + modules: {}, + }, + { spaces: 2 }, + ); + }); + + it("should sort objects after updating", async () => { + const writer = new FileHashWriter("/test/selectivity"); + const dependencies = { + css: ["src/styles.css"], + js: [], + modules: ["node_modules/react"], + }; + + fsExtraStub.exists.resolves(false); + fileHashProviderMock.calculateFor + .withArgs("src/styles.css") + .resolves("css-hash") + .withArgs("node_modules/react/package.json") + .resolves("module-hash"); + + writer.add(dependencies); + await writer.commit(); + + assert.calledTwice(shallowSortObjectStub); + }); + }); + + describe("getFileHashWriter", () => { + it("should return memoized instance", () => { + const path1 = "/test/path1"; + const path2 = "/test/path2"; + + const writer1a = getFileHashWriter(path1); + const writer1b = getFileHashWriter(path1); + const writer2 = getFileHashWriter(path2); + + assert.equal(writer1a, writer1b); + assert.notEqual(writer1a, writer2); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/index.ts b/test/src/browser/cdp/selectivity/index.ts new file mode 100644 index 000000000..49983dd0b --- /dev/null +++ b/test/src/browser/cdp/selectivity/index.ts @@ -0,0 +1,273 @@ +import sinon, { SinonStub } from "sinon"; +import proxyquire from "proxyquire"; +import type { ExistingBrowser } from "src/browser/existing-browser"; +import type { Test } from "src/types"; + +describe("CDP/Selectivity", () => { + const sandbox = sinon.createSandbox(); + let startSelectivity: typeof import("src/browser/cdp/selectivity/index").startSelectivity; + let CSSSelectivityStub: SinonStub; + let JSSelectivityStub: SinonStub; + let getTestDependenciesWriterStub: SinonStub; + let getFileHashWriterStub: SinonStub; + let transformSourceDependenciesStub: SinonStub; + let cssSelectivityMock: { start: SinonStub; stop: SinonStub }; + let jsSelectivityMock: { start: SinonStub; stop: SinonStub }; + let testDependenciesWriterMock: { saveFor: SinonStub }; + let fileHashWriterMock: { add: SinonStub; commit: SinonStub }; + let browserMock: { + config: { + selectivity: { + enabled: boolean; + sourceRoot: string; + testDependenciesPath: string; + }; + }; + publicAPI: { isChromium: boolean; getWindowHandle: SinonStub }; + cdp: { + target: { getTargets: SinonStub; attachToTarget: SinonStub; detachFromTarget: SinonStub }; + } | null; + }; + + beforeEach(() => { + cssSelectivityMock = { + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(["src/styles.css"]), + }; + jsSelectivityMock = { + start: sandbox.stub().resolves(), + stop: sandbox.stub().resolves(["src/app.js"]), + }; + testDependenciesWriterMock = { + saveFor: sandbox.stub().resolves(), + }; + fileHashWriterMock = { + add: sandbox.stub(), + commit: sandbox.stub().resolves(), + }; + + CSSSelectivityStub = sandbox.stub().returns(cssSelectivityMock); + JSSelectivityStub = sandbox.stub().returns(jsSelectivityMock); + getTestDependenciesWriterStub = sandbox.stub().returns(testDependenciesWriterMock); + getFileHashWriterStub = sandbox.stub().returns(fileHashWriterMock); + transformSourceDependenciesStub = sandbox.stub().returns({ + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }); + + browserMock = { + config: { + selectivity: { + enabled: true, + sourceRoot: "/test/source-root", + testDependenciesPath: "/test/dependencies", + }, + }, + publicAPI: { + isChromium: true, + getWindowHandle: sandbox.stub().resolves("CDwindow-target-123"), + }, + cdp: { + target: { + getTargets: sandbox.stub().resolves({ + targetInfos: [{ targetId: "target-123" }], + }), + attachToTarget: sandbox.stub().resolves({ sessionId: "session-123" }), + detachFromTarget: sandbox.stub().resolves(), + }, + }, + }; + + startSelectivity = proxyquire("src/browser/cdp/selectivity/index", { + "./css-selectivity": { CSSSelectivity: CSSSelectivityStub }, + "./js-selectivity": { JSSelectivity: JSSelectivityStub }, + "./test-dependencies-writer": { getTestDependenciesWriter: getTestDependenciesWriterStub }, + "./file-hash-writer": { getFileHashWriter: getFileHashWriterStub }, + "./utils": { transformSourceDependencies: transformSourceDependenciesStub }, + }).startSelectivity; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("startSelectivity", () => { + it("should return no-op function if selectivity is disabled", async () => { + browserMock.config.selectivity.enabled = false; + + const stopFn = await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.isFunction(stopFn); + + await stopFn({ id: "test", browserId: "chrome" } as Test, true); + + assert.notCalled(CSSSelectivityStub); + assert.notCalled(JSSelectivityStub); + }); + + it("should return no-op function if browser is not Chromium", async () => { + browserMock.publicAPI.isChromium = false; + + const stopFn = await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.isFunction(stopFn); + + await stopFn({ id: "test", browserId: "chrome" } as Test, true); + + assert.notCalled(CSSSelectivityStub); + assert.notCalled(JSSelectivityStub); + }); + + it("should throw error if CDP connection is not established", async () => { + browserMock.cdp = null; + + await assert.isRejected( + startSelectivity(browserMock as unknown as ExistingBrowser), + /Selectivity: Devtools connection is not established, couldn't record selectivity without it/, + ); + }); + + it("should throw error if target ID is not found", async () => { + browserMock.publicAPI.getWindowHandle.resolves("unknown-handle"); + browserMock.cdp!.target.getTargets.resolves({ + targetInfos: [{ targetId: "different-target" }], + }); + + await assert.isRejected( + startSelectivity(browserMock as unknown as ExistingBrowser), + /Selectivity: Couldn't find current page/, + ); + }); + + it("should start CSS and JS selectivity and return stop function", async () => { + const stopFn = await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.calledWith(browserMock.cdp!.target.getTargets); + assert.calledWith(browserMock.cdp!.target.attachToTarget, "target-123"); + assert.calledWith(CSSSelectivityStub, browserMock.cdp, "session-123", "/test/source-root"); + assert.calledWith(JSSelectivityStub, browserMock.cdp, "session-123", "/test/source-root"); + assert.calledOnce(cssSelectivityMock.start); + assert.calledOnce(jsSelectivityMock.start); + assert.isFunction(stopFn); + }); + + it("should handle window handle containing target ID", async () => { + browserMock.publicAPI.getWindowHandle.resolves("CDwindow-target-123-suffix"); + browserMock.cdp!.target.getTargets.resolves({ + targetInfos: [{ targetId: "target-123" }], + }); + + const stopFn = await startSelectivity(browserMock as unknown as ExistingBrowser); + + assert.isFunction(stopFn); + assert.calledWith(browserMock.cdp!.target.attachToTarget, "target-123"); + }); + }); + + describe("stopSelectivity", () => { + let stopFn: any; + const mockTest = { id: "test-123", browserId: "chrome" }; + + beforeEach(async () => { + stopFn = await startSelectivity(browserMock as unknown as ExistingBrowser); + }); + + it("should stop selectivity and not save when shouldWrite is false", async () => { + await stopFn(mockTest, false); + + assert.calledWith(cssSelectivityMock.stop, true); + assert.calledWith(jsSelectivityMock.stop, true); + assert.calledWith(browserMock.cdp!.target.detachFromTarget, "session-123"); + assert.notCalled(testDependenciesWriterMock.saveFor); + assert.notCalled(fileHashWriterMock.add); + assert.notCalled(fileHashWriterMock.commit); + }); + + it("should stop selectivity and save dependencies when shouldWrite is true", async () => { + await stopFn(mockTest, true); + + assert.calledWith(cssSelectivityMock.stop, false); + assert.calledWith(jsSelectivityMock.stop, false); + assert.calledWith(transformSourceDependenciesStub, ["src/styles.css"], ["src/app.js"]); + assert.calledWith(getTestDependenciesWriterStub, "/test/dependencies"); + assert.calledWith(getFileHashWriterStub, "/test/dependencies"); + assert.calledWith(fileHashWriterMock.add, { + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }); + assert.calledWith(testDependenciesWriterMock.saveFor, mockTest, { + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }); + assert.calledOnce(fileHashWriterMock.commit); + }); + + it("should not save when no dependencies are found", async () => { + cssSelectivityMock.stop.resolves([]); + jsSelectivityMock.stop.resolves([]); + + await stopFn(mockTest, true); + + assert.notCalled(testDependenciesWriterMock.saveFor); + assert.notCalled(fileHashWriterMock.add); + assert.notCalled(fileHashWriterMock.commit); + }); + + it("should handle CDP detach errors gracefully", async () => { + browserMock.cdp!.target.detachFromTarget.rejects(new Error("Detach failed")); + + await stopFn(mockTest, false); + + assert.calledWith(browserMock.cdp!.target.detachFromTarget, "session-123"); + }); + + it("should handle CSS selectivity errors", async () => { + cssSelectivityMock.stop.rejects(new Error("CSS error")); + + await assert.isRejected(stopFn(mockTest, true), "CSS error"); + }); + + it("should handle JS selectivity errors", async () => { + jsSelectivityMock.stop.rejects(new Error("JS error")); + + await assert.isRejected(stopFn(mockTest, true), "JS error"); + }); + + it("should handle test dependencies writer errors", async () => { + testDependenciesWriterMock.saveFor.rejects(new Error("Save error")); + + await assert.isRejected(stopFn(mockTest, true), "Save error"); + }); + + it("should handle file hash writer errors", async () => { + fileHashWriterMock.commit.rejects(new Error("Commit error")); + + await assert.isRejected(stopFn(mockTest, true), "Commit error"); + }); + + it("should save dependencies when only CSS dependencies exist", async () => { + jsSelectivityMock.stop.resolves([]); + + await stopFn(mockTest, true); + + assert.calledWith(transformSourceDependenciesStub, ["src/styles.css"], []); + assert.calledOnce(testDependenciesWriterMock.saveFor); + assert.calledOnce(fileHashWriterMock.add); + assert.calledOnce(fileHashWriterMock.commit); + }); + + it("should save dependencies when only JS dependencies exist", async () => { + cssSelectivityMock.stop.resolves([]); + + await stopFn(mockTest, true); + + assert.calledWith(transformSourceDependenciesStub, [], ["src/app.js"]); + assert.calledOnce(testDependenciesWriterMock.saveFor); + assert.calledOnce(fileHashWriterMock.add); + assert.calledOnce(fileHashWriterMock.commit); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/js-selectivity.ts b/test/src/browser/cdp/selectivity/js-selectivity.ts new file mode 100644 index 000000000..f8dc3000c --- /dev/null +++ b/test/src/browser/cdp/selectivity/js-selectivity.ts @@ -0,0 +1,437 @@ +import sinon, { SinonStub, type SinonStubbedInstance } from "sinon"; +import proxyquire from "proxyquire"; +import type { CDPTarget } from "src/browser/cdp/domains/target"; +import type { CDPDebugger } from "src/browser/cdp/domains/debugger"; +import type { CDPProfiler } from "src/browser/cdp/domains/profiler"; +import type { CDPRuntime } from "src/browser/cdp/domains/runtime"; +import type { CDP } from "src/browser/cdp"; + +describe("CDP/Selectivity/JSSelectivity", () => { + const sandbox = sinon.createSandbox(); + let JSSelectivity: typeof import("src/browser/cdp/selectivity/js-selectivity").JSSelectivity; + let cdpMock: { + target: SinonStubbedInstance; + debugger: SinonStubbedInstance; + profiler: SinonStubbedInstance; + runtime: SinonStubbedInstance; + }; + let fetchTextWithBrowserFallbackStub: SinonStub; + let extractSourceFilesDepsStub: SinonStub; + let urlResolveStub: SinonStub; + let groupByStub: SinonStub; + + const sessionId = "test-session-id"; + const sourceRoot = "/test/source-root"; + + beforeEach(() => { + cdpMock = { + target: { setAutoAttach: sandbox.stub().resolves() } as SinonStubbedInstance, + debugger: { + enable: sandbox.stub().resolves(), + on: sandbox.stub(), + off: sandbox.stub(), + resume: sandbox.stub().resolves(), + getScriptSource: sandbox + .stub() + .resolves({ scriptSource: "mock source\n//# sourceMappingURL=app.js.map" }), + } as SinonStubbedInstance, + profiler: { + enable: sandbox.stub().resolves(), + startPreciseCoverage: sandbox.stub().resolves(), + takePreciseCoverage: sandbox.stub().resolves({ result: [] }), + } as SinonStubbedInstance, + runtime: {} as SinonStubbedInstance, + }; + + fetchTextWithBrowserFallbackStub = sandbox.stub().resolves("mock source map"); + extractSourceFilesDepsStub = sandbox.stub().resolves(new Set(["src/app.js", "src/utils.js"])); + urlResolveStub = sandbox.stub().returnsArg(1); + groupByStub = sandbox.stub().callsFake((arr, key) => { + const result: Record = {}; + arr.forEach((item: any) => { + const keyValue = item[key]; + if (!result[keyValue]) result[keyValue] = []; + result[keyValue].push(item); + }); + return result; + }); + + JSSelectivity = proxyquire("src/browser/cdp/selectivity/js-selectivity", { + lodash: { groupBy: groupByStub }, + "node:url": { resolve: urlResolveStub }, + "./utils": { + extractSourceFilesDeps: extractSourceFilesDepsStub, + fetchTextWithBrowserFallback: fetchTextWithBrowserFallbackStub, + }, + }).JSSelectivity; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("constructor", () => { + it("should initialize with correct parameters", () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + assert.isOk(jsSelectivity); + }); + + it("should initialize with default sourceRoot", () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId); + + assert.isOk(jsSelectivity); + }); + }); + + describe("start", () => { + it("should set up CDP connections and start coverage", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + + assert.calledWith(cdpMock.target.setAutoAttach, sessionId, { + autoAttach: true, + waitForDebuggerOnStart: false, + }); + assert.calledWith(cdpMock.debugger.enable, sessionId); + assert.calledWith(cdpMock.profiler.enable, sessionId); + assert.calledWith(cdpMock.profiler.startPreciseCoverage, sessionId, { + callCount: false, + detailed: false, + allowTriggeredUpdates: false, + }); + assert.calledTwice(cdpMock.debugger.on); // paused and scriptParsed events + }); + + it("should handle debugger paused events", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + + const pausedHandler = cdpMock.debugger.on.getCall(0).args[1]; + + await pausedHandler({}); + + assert.calledWith(cdpMock.debugger.resume, sessionId); + }); + + it("should handle scriptParsed events", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + + const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + + const scriptParsedEvent = { + scriptId: "script-123", + url: "http://example.com/app.js", + sourceMapURL: "app.js.map", + }; + + scriptParsedHandler(scriptParsedEvent); + + assert.calledWith(cdpMock.debugger.getScriptSource, sessionId, "script-123"); + assert.calledWith(fetchTextWithBrowserFallbackStub, "app.js.map", cdpMock.runtime, sessionId); + }); + + it("should handle scriptParsed events without URL or sourceMapURL", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + + const scriptParsedHandler = cdpMock.debugger.on.getCall(1).args[1]; + + const scriptParsedEvent = { + scriptId: "script-123", + url: "", + sourceMapURL: "", + }; + + scriptParsedHandler(scriptParsedEvent); + + assert.notCalled(cdpMock.debugger.getScriptSource); + assert.notCalled(fetchTextWithBrowserFallbackStub); + }); + }); + + describe("stop", () => { + it("should return empty array when drop is true", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + await jsSelectivity.start(); + const result = await jsSelectivity.stop(true); + + assert.deepEqual(result, []); + assert.calledTwice(cdpMock.debugger.off); // Remove both event listeners + }); + + it("should process coverage and return dependencies", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [ + { startOffset: 0, endOffset: 30, count: 1 }, + { startOffset: 100, endOffset: 130, count: 2 }, + ], + }, + ], + }, + ], + }; + + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ + scriptSource: "mock source\n//# sourceMappingURL=app.js.map", + }); + extractSourceFilesDepsStub.resolves(new Set(["src/app.js", "src/utils.js", "src/styles.css"])); + + await jsSelectivity.start(); + const result = await jsSelectivity.stop(); + + assert.calledWith(cdpMock.profiler.takePreciseCoverage, sessionId); + assert.calledWith( + extractSourceFilesDepsStub, + "mock source\n//# sourceMappingURL=app.js.map", + "mock source map", + [0, 100], + sourceRoot, + ); + + assert.deepEqual(result, ["src/app.js", "src/utils.js"]); + }); + + it("should handle scripts without URL", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "", + functions: [], + }, + { + scriptId: "script-456", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ + scriptSource: "mock source\n//# sourceMappingURL=app.js.map", + }); + + await jsSelectivity.start(); + await jsSelectivity.stop(); + + assert.calledOnce(extractSourceFilesDepsStub); + }); + + it("should handle missing scriptParsed events by fetching source manually", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + const sourceWithSourceMap = ` + console.log("test"); + //# sourceMappingURL=app.js.map + `; + + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ scriptSource: sourceWithSourceMap }); + + await jsSelectivity.start(); + await jsSelectivity.stop(); + + assert.calledWith(cdpMock.debugger.getScriptSource, sessionId, "script-123"); + assert.calledWith(fetchTextWithBrowserFallbackStub, "app.js.map", cdpMock.runtime, sessionId); + }); + + it("should handle source code without source map comment", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + const sourceWithoutSourceMap = "console.log('test');"; + + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ scriptSource: sourceWithoutSourceMap }); + + await jsSelectivity.start(); + + await assert.isRejected( + jsSelectivity.stop(), + /JS Selectivity: Couldn't load source maps of.*Source maping url comment is missing/, + ); + }); + + it("should handle source fetch errors", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + const sourceError = new Error("Failed to fetch source"); + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.rejects(sourceError); + + await jsSelectivity.start(); + + await assert.isRejected( + jsSelectivity.stop(), + /JS Selectivity: Couldn't load source code at.*Failed to fetch source/, + ); + }); + + it("should handle source map fetch errors", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + const sourceWithSourceMap = ` + console.log("test"); + //# sourceMappingURL=app.js.map + `; + + const sourceMapError = new Error("Failed to fetch source map"); + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ scriptSource: sourceWithSourceMap }); + fetchTextWithBrowserFallbackStub.resolves(sourceMapError); + + await jsSelectivity.start(); + + await assert.isRejected( + jsSelectivity.stop(), + /JS Selectivity: Couldn't load source maps of.*Failed to fetch source map/, + ); + }); + + it("should filter out non-source-code files", async () => { + const jsSelectivity = new JSSelectivity(cdpMock as unknown as CDP, sessionId, sourceRoot); + + const mockCoverage = { + timestamp: 100500, + result: [ + { + scriptId: "script-123", + url: "http://example.com/app.js", + functions: [ + { + functionName: "foo", + isBlockCoverage: false, + ranges: [{ startOffset: 0, endOffset: 30, count: 1 }], + }, + ], + }, + ], + }; + + extractSourceFilesDepsStub.resolves( + new Set([ + "src/app.js", + "src/utils.ts", + "src/component.jsx", + "src/styles.css", + "src/image.png", + "src/data.json", + "src/module.mjs", + "src/config.cjs", + ]), + ); + + cdpMock.profiler.takePreciseCoverage.resolves(mockCoverage); + cdpMock.debugger.getScriptSource.resolves({ + scriptSource: "mock source\n//# sourceMappingURL=app.js.map", + }); + + await jsSelectivity.start(); + const result = await jsSelectivity.stop(); + + assert.deepEqual(result, [ + "src/app.js", + "src/component.jsx", + "src/config.cjs", + "src/module.mjs", + "src/utils.ts", + ]); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/test-dependencies-writer.ts b/test/src/browser/cdp/selectivity/test-dependencies-writer.ts new file mode 100644 index 000000000..a5e4175c7 --- /dev/null +++ b/test/src/browser/cdp/selectivity/test-dependencies-writer.ts @@ -0,0 +1,297 @@ +import sinon, { SinonStub } from "sinon"; +import proxyquire from "proxyquire"; + +describe("CDP/Selectivity/TestDependenciesWriter", () => { + const sandbox = sinon.createSandbox(); + let TestDependenciesWriter: any; + let getTestDependenciesWriter: typeof import("src/browser/cdp/selectivity/test-dependencies-writer").getTestDependenciesWriter; + let fsExtraStub: { ensureDir: SinonStub; existsSync: SinonStub; readFile: SinonStub; writeFile: SinonStub }; + let pathStub: { join: SinonStub }; + let shallowSortObjectStub: SinonStub; + + beforeEach(() => { + fsExtraStub = { + ensureDir: sandbox.stub().resolves(), + existsSync: sandbox.stub().returns(false), + readFile: sandbox.stub().resolves("{}"), + writeFile: sandbox.stub().resolves(), + }; + pathStub = { + join: sandbox.stub().callsFake((...args) => args.join("/")), + }; + shallowSortObjectStub = sandbox.stub(); + + const proxyquiredModule = proxyquire("src/browser/cdp/selectivity/test-dependencies-writer", { + "node:path": pathStub, + "fs-extra": fsExtraStub, + "./utils": { shallowSortObject: shallowSortObjectStub }, + }); + + TestDependenciesWriter = proxyquiredModule.TestDependenciesWriter; + getTestDependenciesWriter = proxyquiredModule.getTestDependenciesWriter; + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("constructor", () => { + it("should initialize with correct paths", () => { + const selectivityRootPath = "/test/selectivity"; + new TestDependenciesWriter(selectivityRootPath); + + assert.calledWith(pathStub.join, selectivityRootPath, "tests"); + }); + }); + + describe("saveFor", () => { + const mockTest = { + id: "test-123", + browserId: "chrome", + }; + + const mockDependencies = { + css: ["src/styles.css"], + js: ["src/app.js"], + modules: ["node_modules/react"], + }; + + it("should create directory on first save", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + + await writer.saveFor(mockTest, mockDependencies); + + assert.calledOnceWith(fsExtraStub.ensureDir, "/test/selectivity/tests"); + }); + + it("should not create directory on subsequent saves", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + + await writer.saveFor(mockTest, mockDependencies); + await writer.saveFor(mockTest, mockDependencies); + + assert.calledOnce(fsExtraStub.ensureDir); + }); + + it("should save new test dependencies", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + fsExtraStub.existsSync.returns(false); + + await writer.saveFor(mockTest, mockDependencies); + + const expectedPath = "/test/selectivity/tests/test-123.json"; + const expectedContent = JSON.stringify( + { + chrome: { browser: mockDependencies }, + }, + null, + 2, + ); + + assert.calledWith(pathStub.join, "/test/selectivity/tests", "test-123.json"); + assert.calledWith(fsExtraStub.writeFile, expectedPath, expectedContent); + assert.calledOnce(shallowSortObjectStub); + }); + + it("should update existing test dependencies", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + firefox: { browser: { css: ["old.css"], js: [], modules: [] } }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + await writer.saveFor(mockTest, mockDependencies); + + const expectedContent = JSON.stringify( + { + firefox: { browser: { css: ["old.css"], js: [], modules: [] } }, + chrome: { browser: mockDependencies }, + }, + null, + 2, + ); + + assert.calledWith(fsExtraStub.writeFile, "/test/selectivity/tests/test-123.json", expectedContent); + }); + + it("should not save if dependencies are the same", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + chrome: { browser: mockDependencies }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + await writer.saveFor(mockTest, mockDependencies); + + assert.notCalled(fsExtraStub.writeFile); + }); + + it("should handle corrupted JSON file", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves("invalid json"); + + await writer.saveFor(mockTest, mockDependencies); + + const expectedContent = JSON.stringify( + { + chrome: { browser: mockDependencies }, + }, + null, + 2, + ); + + assert.calledWith(fsExtraStub.writeFile, "/test/selectivity/tests/test-123.json", expectedContent); + }); + + it("should handle empty file", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(""); + + await writer.saveFor(mockTest, mockDependencies); + + const expectedContent = JSON.stringify( + { + chrome: { browser: mockDependencies }, + }, + null, + 2, + ); + + assert.calledWith(fsExtraStub.writeFile, "/test/selectivity/tests/test-123.json", expectedContent); + }); + + it("should overwrite existing browser dependencies", async () => { + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + chrome: { browser: { css: ["old.css"], js: [], modules: [] } }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + await writer.saveFor(mockTest, mockDependencies); + + const expectedContent = JSON.stringify( + { + chrome: { browser: mockDependencies }, + }, + null, + 2, + ); + + assert.calledWith(fsExtraStub.writeFile, "/test/selectivity/tests/test-123.json", expectedContent); + }); + }); + + describe("areDepsSame", () => { + it("should return true for identical dependencies", () => { + const deps1 = { + css: ["a.css", "b.css"], + js: ["a.js", "b.js"], + modules: ["react", "lodash"], + }; + const deps2 = { + css: ["a.css", "b.css"], + js: ["a.js", "b.js"], + modules: ["react", "lodash"], + }; + + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + chrome: { browser: deps1 }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + return writer.saveFor({ id: "test", browserId: "chrome" }, deps2).then(() => { + assert.notCalled(fsExtraStub.writeFile); + }); + }); + + it("should return false for different dependencies", async () => { + const deps1 = { + css: ["a.css"], + js: ["a.js"], + modules: ["react"], + }; + const deps2 = { + css: ["b.css"], + js: ["b.js"], + modules: ["lodash"], + }; + + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + chrome: { browser: deps1 }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + await writer.saveFor({ id: "test", browserId: "chrome" }, deps2); + + assert.calledOnce(fsExtraStub.writeFile); + }); + + it("should return false for undefined dependencies", async () => { + const deps = { + css: ["a.css"], + js: ["a.js"], + modules: ["react"], + }; + + const writer = new TestDependenciesWriter("/test/selectivity"); + fsExtraStub.existsSync.returns(false); + + await writer.saveFor({ id: "test", browserId: "chrome" }, deps); + + assert.calledOnce(fsExtraStub.writeFile); + }); + + it("should return false for different array lengths", async () => { + const deps1 = { + css: ["a.css"], + js: ["a.js"], + modules: ["react"], + }; + const deps2 = { + css: ["a.css", "b.css"], + js: ["a.js"], + modules: ["react"], + }; + + const writer = new TestDependenciesWriter("/test/selectivity"); + const existingContent = JSON.stringify({ + chrome: { browser: deps1 }, + }); + + fsExtraStub.existsSync.returns(true); + fsExtraStub.readFile.resolves(existingContent); + + await writer.saveFor({ id: "test", browserId: "chrome" }, deps2); + + assert.calledOnce(fsExtraStub.writeFile); + }); + }); + + describe("getTestDependenciesWriter", () => { + it("should return memoized instance", () => { + const path1 = "/test/path1"; + const path2 = "/test/path2"; + + const writer1a = getTestDependenciesWriter(path1); + const writer1b = getTestDependenciesWriter(path1); + const writer2 = getTestDependenciesWriter(path2); + + assert.equal(writer1a, writer1b); + assert.notEqual(writer1a, writer2); + }); + }); +}); diff --git a/test/src/browser/cdp/selectivity/utils.ts b/test/src/browser/cdp/selectivity/utils.ts new file mode 100644 index 000000000..8a5e8ff75 --- /dev/null +++ b/test/src/browser/cdp/selectivity/utils.ts @@ -0,0 +1,363 @@ +import sinon, { SinonStub, type SinonStubbedInstance } from "sinon"; +import proxyquire from "proxyquire"; +import type { CDPRuntime } from "src/browser/cdp/domains/runtime"; + +describe("CDP/Selectivity/Utils", () => { + const sandbox = sinon.createSandbox(); + let utils: typeof import("src/browser/cdp/selectivity/utils"); + let fetchStub: SinonStub; + let fsStub: { existsSync: SinonStub }; + let pathStub: { posix: { relative: SinonStub; resolve: SinonStub; join: SinonStub; sep: string } }; + let softFileURLToPathStub: SinonStub; + let SourceMapConsumerStub: SinonStub; + + beforeEach(() => { + fetchStub = sandbox.stub(globalThis, "fetch").resolves({ + text: sandbox.stub().resolves("mocked response"), + ok: true, + status: 200, + headers: new Headers(), + redirected: false, + statusText: "OK", + type: "basic", + url: "", + clone: sandbox.stub(), + body: null, + bodyUsed: false, + arrayBuffer: sandbox.stub(), + blob: sandbox.stub(), + formData: sandbox.stub(), + json: sandbox.stub(), + }); + fsStub = { existsSync: sandbox.stub().returns(true) }; + pathStub = { + posix: { + relative: sandbox.stub().returnsArg(1), + resolve: sandbox.stub().returnsArg(0), + join: sandbox.stub().callsFake((...args) => args.join("/")), + sep: "/", + }, + }; + softFileURLToPathStub = sandbox.stub().returnsArg(0); + SourceMapConsumerStub = sandbox.stub(); + + utils = proxyquire("src/browser/cdp/selectivity/utils", { + fs: fsStub, + path: pathStub, + "source-map": { + SourceMapConsumer: SourceMapConsumerStub, + }, + "../../../utils/fs": { + softFileURLToPath: softFileURLToPathStub, + }, + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe("fetchTextWithBrowserFallback", () => { + let runtimeStub: SinonStubbedInstance; + const sessionId = "test-session-id"; + + beforeEach(() => { + runtimeStub = { evaluate: sandbox.stub() } as SinonStubbedInstance; + }); + + it("should fetch embedded source maps directly", async () => { + const dataUrl = "data:application/json;base64,eyJ2ZXJzaW9uIjozfQ=="; + fetchStub.resolves({ text: () => Promise.resolve("source map content") }); + + const result = await utils.fetchTextWithBrowserFallback(dataUrl, runtimeStub, sessionId); + + assert.equal(result, "source map content"); + assert.calledOnceWith(fetchStub, dataUrl); + assert.notCalled(runtimeStub.evaluate); + }); + + it("should try direct fetch first for non-embedded URLs", async () => { + const url = "http://example.com/sourcemap.js.map"; + fetchStub.resolves({ text: () => Promise.resolve("source map content") }); + + const result = await utils.fetchTextWithBrowserFallback(url, runtimeStub, sessionId); + + assert.equal(result, "source map content"); + assert.calledOnceWith(fetchStub, url); + assert.notCalled(runtimeStub.evaluate); + }); + + it("should fallback to browser evaluation if direct fetch fails", async () => { + const url = "http://example.com/sourcemap.js.map"; + fetchStub.rejects(new Error("Network error")); + runtimeStub.evaluate.resolves({ result: { type: "string", value: "browser fetched content" } }); + + const result = await utils.fetchTextWithBrowserFallback(url, runtimeStub, sessionId); + + assert.equal(result, "browser fetched content"); + assert.calledOnceWith(fetchStub, url); + assert.calledOnceWith(runtimeStub.evaluate, sessionId, { + expression: `fetch("${url}").then(r => r.text())`, + awaitPromise: true, + returnByValue: true, + }); + }); + + it("should return error if both fetch methods fail", async () => { + const url = "http://example.com/sourcemap.js.map"; + const browserError = new Error("Browser fetch failed"); + fetchStub.rejects(new Error("Network error")); + runtimeStub.evaluate.rejects(browserError); + + const result = await utils.fetchTextWithBrowserFallback(url, runtimeStub, sessionId); + + assert.instanceOf(result, Error); + assert.equal((result as Error).message, "Browser fetch failed"); + }); + + it("should return error if embedded source map fetch fails", async () => { + const dataUrl = "data:application/json;base64,invalid"; + const fetchError = new Error("Invalid data URL"); + fetchStub.rejects(fetchError); + + const result = await utils.fetchTextWithBrowserFallback(dataUrl, runtimeStub, sessionId); + + assert.instanceOf(result, Error); + assert.equal((result as Error).message, "Invalid data URL"); + }); + }); + + describe("patchSourceMapSources", () => { + it("should patch webpack protocol sources", () => { + const sourceMap = { + version: 3, + sources: ["webpack:///src/app.js", "webpack:///src/utils.js", "regular/file.js"], + sourceRoot: "", + names: [], + mappings: "", + file: "bundle.js", + }; + + const result = utils.patchSourceMapSources(sourceMap, "/custom/root"); + + assert.deepEqual(result.sources, ["src/app.js", "src/utils.js", "regular/file.js"]); + assert.equal(result.sourceRoot, "/custom/root"); + }); + + it("should use existing sourceRoot if no custom sourceRoot provided", () => { + const sourceMap = { + version: 3, + sources: ["webpack:///src/app.js"], + sourceRoot: "/existing/root", + names: [], + mappings: "", + file: "bundle.js", + }; + + const result = utils.patchSourceMapSources(sourceMap); + + assert.equal(result.sourceRoot, "/existing/root"); + }); + + it("should handle sources without webpack protocol", () => { + const sourceMap = { + version: 3, + sources: ["src/app.js", "lib/utils.js"], + sourceRoot: "", + names: [], + mappings: "", + file: "bundle.js", + }; + + const result = utils.patchSourceMapSources(sourceMap, "/root"); + + assert.deepEqual(result.sources, ["src/app.js", "lib/utils.js"]); + }); + }); + + describe("extractSourceFilesDeps", () => { + let consumerMock: { originalPositionFor: SinonStub }; + + beforeEach(() => { + consumerMock = { originalPositionFor: sandbox.stub() }; + SourceMapConsumerStub.resolves(consumerMock); + }); + + it("should extract source files from coverage offsets", async () => { + const source = "line1\nline2\nline3\nline4"; + const sourceMaps = JSON.stringify({ + version: 3, + sources: ["src/app.js"], + sourceRoot: "/root", + names: [], + mappings: "", + file: "bundle.js", + }); + const startOffsets = [0, 6, 12]; + + consumerMock.originalPositionFor + .onCall(0) + .returns({ source: "src/app.js" }) + .onCall(1) + .returns({ source: "src/utils.js" }) + .onCall(2) + .returns({ source: null }); + + const result = await utils.extractSourceFilesDeps(source, sourceMaps, startOffsets, "/root"); + + assert.equal(result.size, 2); + assert.isTrue(result.has("src/app.js")); + assert.isTrue(result.has("src/utils.js")); + }); + + it("should handle empty start offsets", async () => { + const source = "line1\nline2"; + const sourceMaps = JSON.stringify({ + version: 3, + sources: ["src/app.js"], + sourceRoot: "/root", + names: [], + mappings: "", + file: "bundle.js", + }); + + const result = await utils.extractSourceFilesDeps(source, sourceMaps, [], "/root"); + + assert.equal(result.size, 0); + }); + }); + + describe("hasProtocol", () => { + it("should return true for URLs with protocol", () => { + assert.isTrue(utils.hasProtocol("http://example.com")); + assert.isTrue(utils.hasProtocol("https://example.com")); + assert.isTrue(utils.hasProtocol("file:///path/to/file")); + assert.isTrue(utils.hasProtocol("webpack://module")); + }); + + it("should return false for paths without protocol", () => { + assert.isFalse(utils.hasProtocol("src/app.js")); + assert.isFalse(utils.hasProtocol("/absolute/path")); + assert.isFalse(utils.hasProtocol("relative/path")); + }); + + it("should return false for invalid URLs", () => { + assert.isFalse(utils.hasProtocol("not-a-url:/")); + assert.isFalse(utils.hasProtocol("://invalid")); + }); + }); + + describe("transformSourceDependencies", () => { + beforeEach(() => { + pathStub.posix.relative.callsFake((from, to) => to.replace(from + "/", "")); + pathStub.posix.resolve.callsFake(path => path || "/current/dir"); + }); + + it("should classify dependencies into css, js, and modules", () => { + const cssDeps = ["src/styles.css", "../node_modules/lib/style.css"]; + const jsDeps = ["src/app.js", "node_modules/react/index.js"]; + + fsStub.existsSync.returns(true); + + const result = utils.transformSourceDependencies(cssDeps, jsDeps); + + assert.deepEqual(result.css, ["src/styles.css"]); + assert.deepEqual(result.js, ["src/app.js"]); + assert.deepEqual(result.modules, ["node_modules/react", "../node_modules/lib"]); + }); + + it("should handle scoped packages", () => { + const cssDeps: string[] = []; + const jsDeps: string[] = ["node_modules/@scope/package/index.js"]; + + fsStub.existsSync.returns(true); + + const result = utils.transformSourceDependencies(cssDeps, jsDeps); + + assert.deepEqual(result.modules, ["node_modules/@scope/package"]); + }); + + it("should throw error for unsupported protocols", () => { + const cssDeps: string[] = ["ftp://example.com/style.css"]; + const jsDeps: string[] = []; + + assert.throws(() => { + utils.transformSourceDependencies(cssDeps, jsDeps); + }, /Selectivity: Found unsupported protocol/); + }); + + it("should throw error if dependency file doesn't exist", () => { + const cssDeps: string[] = ["src/missing.css"]; + const jsDeps: string[] = []; + + fsStub.existsSync.returns(false); + + assert.throws(() => { + utils.transformSourceDependencies(cssDeps, jsDeps); + }, /Selectivity: Couldn;t find/); + }); + + it("should decode URI components", () => { + const cssDeps: string[] = ["src/file%20with%20spaces.css"]; + const jsDeps: string[] = []; + + softFileURLToPathStub.returns("src/file with spaces.css"); + pathStub.posix.relative.returns("src/file with spaces.css"); + fsStub.existsSync.returns(true); + + const result = utils.transformSourceDependencies(cssDeps, jsDeps); + + assert.calledWith(softFileURLToPathStub, "src/file%20with%20spaces.css"); + assert.deepEqual(result.css, ["src/file with spaces.css"]); + }); + }); + + describe("shallowSortObject", () => { + it("should make result json have ordered properties", () => { + const obj = { + c: "3", + a: "1", + b: "2", + }; + + utils.shallowSortObject(obj); + + assert.equal(JSON.stringify(obj), '{"a":"1","b":"2","c":"3"}'); + }); + + it("should sort object keys alphabetically", () => { + const obj = { + zebra: "value1", + alpha: "value2", + beta: "value3", + }; + + utils.shallowSortObject(obj); + + const keys = Object.keys(obj); + assert.deepEqual(keys, ["alpha", "beta", "zebra"]); + }); + + it("should preserve values after sorting", () => { + const obj = { + c: { nested: "value3" }, + a: { nested: "value1" }, + b: { nested: "value2" }, + }; + + utils.shallowSortObject(obj); + + assert.deepEqual(obj.a, { nested: "value1" }); + assert.deepEqual(obj.b, { nested: "value2" }); + assert.deepEqual(obj.c, { nested: "value3" }); + }); + + it("should handle empty object", () => { + const obj = {}; + + utils.shallowSortObject(obj); + + assert.deepEqual(obj, {}); + }); + }); +}); diff --git a/test/src/worker/runner/test-runner/index.js b/test/src/worker/runner/test-runner/index.js index 4289bd361..b01c23e4b 100644 --- a/test/src/worker/runner/test-runner/index.js +++ b/test/src/worker/runner/test-runner/index.js @@ -86,10 +86,14 @@ describe("worker/runner/test-runner", () => { beforeEach(() => { historyRunGroupStub = sandbox.stub().callsFake(history.runGroup); + TestRunner = proxyquire("src/worker/runner/test-runner", { "../../../browser/history": { runGroup: historyRunGroupStub, }, + "../../../browser/cdp/selectivity": { + startSelectivity: sandbox.stub().resolves(() => Promise.resolve()), + }, }); sandbox.stub(BrowserAgent.prototype, "getBrowser").resolves(mkBrowser_()); @@ -917,6 +921,61 @@ describe("worker/runner/test-runner", () => { }); }); }); + + describe("selectivity integration", () => { + let startSelectivityStub, stopSelectivityStub; + + beforeEach(() => { + stopSelectivityStub = sandbox.stub().resolves(); + startSelectivityStub = sandbox.stub().resolves(stopSelectivityStub); + + TestRunner = proxyquire("src/worker/runner/test-runner", { + "../../../browser/history": { + runGroup: historyRunGroupStub, + }, + "../../../browser/cdp/selectivity": { + startSelectivity: startSelectivityStub, + }, + }); + }); + + it("should call startSelectivity with browser", async () => { + const browser = mkBrowser_(); + BrowserAgent.prototype.getBrowser.resolves(browser); + + await run_(); + + assert.calledOnceWith(startSelectivityStub, browser); + }); + + it("should call stopSelectivity with test and no error flag when no error", async () => { + const test = mkTest_(); + const browser = mkBrowser_(); + BrowserAgent.prototype.getBrowser.resolves(browser); + + await run_({ test }); + + assert.calledOnce(stopSelectivityStub); + assert.calledWith(stopSelectivityStub, sinon.match.object, false); + }); + + it("should call stopSelectivity with test and failure flag when error occurs", async () => { + const test = mkTest_({ fn: sinon.stub().rejects(new Error("test error")) }); + const browser = mkBrowser_(); + BrowserAgent.prototype.getBrowser.resolves(browser); + + await run_({ test }).catch(() => {}); + + assert.calledOnce(stopSelectivityStub); + assert.calledWith(stopSelectivityStub, sinon.match.object, true); + }); + + it("should propagate stopSelectivity errors", async () => { + stopSelectivityStub.rejects(new Error("stop selectivity error")); + + await assert.isRejected(run_(), /stop selectivity error/); + }); + }); }); describe("isLastTestFailed flag initialization", () => {