diff --git a/.gitignore b/.gitignore index 117af42..6b7f09b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,8 +34,9 @@ selenium-video-*.webm nightwatch-video-*.webm packages/nightwatch-devtools/nightwatch-video-*.webm -# trace.zip output (mode: 'trace') +# trace output (mode: 'trace') trace-*.zip +examples/**/trace-*/ # vitest --coverage output coverage/ diff --git a/examples/wdio/package.json b/examples/wdio/package.json index a3c3644..b9d9ff0 100644 --- a/examples/wdio/package.json +++ b/examples/wdio/package.json @@ -17,6 +17,7 @@ }, "scripts": { "wdio": "wdio run ./wdio.conf.ts", - "mobile": "wdio run ./wdio.mobile.conf.ts" + "mobile": "wdio run ./wdio.mobile.conf.ts", + "trace": "wdio run ./wdio.trace.conf.ts" } } diff --git a/examples/wdio/wdio.trace.conf.ts b/examples/wdio/wdio.trace.conf.ts new file mode 100644 index 0000000..346ebaf --- /dev/null +++ b/examples/wdio/wdio.trace.conf.ts @@ -0,0 +1,64 @@ +import path from 'node:path' + +const __dirname = path.resolve(path.dirname(new URL(import.meta.url).pathname)) + +export const config: WebdriverIO.Config = { + runner: 'local', + tsConfigPath: './tsconfig.json', + + specs: ['./features/login.feature'], + + maxInstances: 1, + + capabilities: [ + { + browserName: 'chrome', + 'goog:chromeOptions': { + args: [ + '--headless', + '--disable-gpu', + '--remote-allow-origins=*', + '--window-size=1600,1200' + ] + } + } + ], + + logLevel: 'warn', + + baseUrl: 'http://localhost', + + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + + services: [ + [ + 'devtools', + { + mode: 'trace' as const + // traceFormat: 'ndjson-directory' + } + ] + ], + + framework: 'cucumber', + + reporters: ['spec'], + + cucumberOpts: { + require: [ + path.resolve(__dirname, 'features', 'step-definitions', 'steps.ts') + ], + backtrace: false, + requireModule: [], + dryRun: false, + failFast: false, + snippets: true, + source: true, + strict: false, + tagExpression: '', + timeout: 60000, + ignoreUndefinedDefinitions: false + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 2e2325a..1623ab3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,7 +38,7 @@ }, "types": "./src/index.ts", "scripts": { - "lint": "eslint ." + "lint": "eslint . --fix" }, "license": "MIT", "devDependencies": { diff --git a/packages/core/src/action-mapping.ts b/packages/core/src/action-mapping.ts index 43a3980..f7f9a40 100644 --- a/packages/core/src/action-mapping.ts +++ b/packages/core/src/action-mapping.ts @@ -58,8 +58,13 @@ export function formatActionTitle( if (firstArg === undefined) { return `${action.class}.${action.method}()` } - const label = ( + const label = typeof firstArg === 'object' ? JSON.stringify(firstArg) : String(firstArg) - ).slice(0, 80) return `${action.class}.${action.method}("${label}")` } + +/** + * Methods where the first positional argument should render as value= in the + * transcript line (e.g. setValue, selectByVisibleText). + */ +export const FILL_METHODS = new Set(['fill', 'selectOption']) diff --git a/packages/core/src/action-snapshot.ts b/packages/core/src/action-snapshot.ts index 43affeb..19060f4 100644 --- a/packages/core/src/action-snapshot.ts +++ b/packages/core/src/action-snapshot.ts @@ -3,8 +3,16 @@ // (timeouts, fallbacks, snapshot serialization) lives in one place. import { accessibilityTreeScript, elementsScript } from './element-scripts.js' -import { serializeWebSnapshot } from './element-snapshot.js' +import { + serializeWebSnapshot, + serializeMobileSnapshot +} from './element-snapshot.js' import type { AccessibilityNode, BrowserElementInfo } from './element-types.js' +import { xmlToJSON } from './locators/xml-parsing.js' +import { + generateAllElementLocators, + getDefaultFilters +} from './locators/index.js' import { SNAPSHOT_PROBE_TIMEOUT_MS, withTimeout } from './with-timeout.js' import type { ActionSnapshot } from '@wdio/devtools-shared' @@ -12,17 +20,26 @@ export type ScriptRunner = (scriptSrc: string) => Promise export interface CaptureActionSnapshotInput { command: string - runScript: ScriptRunner + /** Browser script runner — omit on native mobile where Appium can't execute JS. */ + runScript?: ScriptRunner takeScreenshot?: () => Promise getUrl?: () => Promise getTitle?: () => Promise + /** Page-source XML fetcher for native mobile — used instead of runScript. */ + getPageSource?: () => Promise + /** Platform identifier for mobile snapshot formatting ('android' | 'ios'). */ + platform?: 'android' | 'ios' } async function runWith( - runScript: ScriptRunner, + runScript: ScriptRunner | undefined, scriptSrc: string, fallback: T ): Promise { + if (!runScript) { + return fallback + } + return withTimeout( runScript(scriptSrc).then((r) => r as T), SNAPSHOT_PROBE_TIMEOUT_MS, @@ -35,10 +52,15 @@ export async function captureActionSnapshot( ): Promise { try { const timestamp = Date.now() - const [shot, url, title, tree, elements] = await Promise.all([ - input.takeScreenshot?.().catch(() => null) ?? Promise.resolve(null), - input.getUrl?.().catch(() => undefined) ?? Promise.resolve(undefined), - input.getTitle?.().catch(() => undefined) ?? Promise.resolve(undefined), + const isNativeMobile = !input.runScript && !!input.getPageSource + + const [shot, url, title, pageSource, tree, elements] = await Promise.all([ + input.takeScreenshot?.().catch(() => null), + input.getUrl?.().catch(() => undefined), + input.getTitle?.().catch(() => undefined), + isNativeMobile + ? input.getPageSource?.().catch(() => undefined) + : undefined, runWith( input.runScript, accessibilityTreeScript(true), @@ -50,14 +72,47 @@ export async function captureActionSnapshot( [] ) ]) - const snapshotText = serializeWebSnapshot(tree, { url, title }) + + let snapshotText: string + let finalElements: unknown[] = elements + + if (isNativeMobile && pageSource) { + const platform = input.platform ?? 'android' + const jsonTree = xmlToJSON(pageSource) + if (jsonTree) { + jsonTree.attributes._sourceXML = pageSource + snapshotText = serializeMobileSnapshot(jsonTree, { + platform, + sourceXML: pageSource + }) + } else { + snapshotText = `[${platform}]` + } + // Generate mobile element locators from the page source XML. + try { + const viewport = { width: 9999, height: 9999 } + const filters = getDefaultFilters(platform, false) + const locators = generateAllElementLocators(pageSource, { + platform, + viewportSize: viewport, + filters, + inViewportOnly: false + }) + finalElements = locators + } catch { + // Non-fatal — snapshot text is the primary deliverable. + } + } else { + snapshotText = serializeWebSnapshot(tree, { url, title }) + } + return { timestamp, command: input.command, url, title, screenshot: shot ?? undefined, - elements, + elements: finalElements, snapshotText } } catch { diff --git a/packages/core/src/trace-exporter.ts b/packages/core/src/trace-exporter.ts index 27374a5..8280895 100644 --- a/packages/core/src/trace-exporter.ts +++ b/packages/core/src/trace-exporter.ts @@ -13,7 +13,12 @@ import type { TraceLog, TraceMutation } from '@wdio/devtools-shared' -import { formatActionTitle, mapCommandToAction } from './action-mapping.js' +import { + formatActionTitle, + mapCommandToAction, + FILL_METHODS, + type TraceAction +} from './action-mapping.js' import { networkRequestToHar } from './trace-har.js' import { buildTraceZip, type TraceZipResource } from './trace-zip-writer.js' @@ -46,6 +51,8 @@ interface BeforeEvent { pageId: string params: Record title: string + /** Playwright-compatible API name (e.g. 'page.goBack', 'element.click'). */ + apiName: string } interface AfterEvent { @@ -60,6 +67,7 @@ interface ScreencastFrameEvent { pageId: string sha1: string elements?: string + snapshot?: string width: number height: number timestamp: number @@ -115,7 +123,12 @@ function buildContextOptions( libraryName: LIBRARY_NAME, libraryVersion: LIBRARY_VERSION, browserName, - platform: process.platform, + platform: + process.platform === 'darwin' + ? 'darwin' + : process.platform === 'win32' + ? 'windows' + : 'linux', wallTime, monotonicTime: 0, sdkLanguage: 'javascript', @@ -133,8 +146,6 @@ function buildActionEvents( wallTime: number ): TraceEvent[] { const events: TraceEvent[] = [] - // cmd.timestamp records command completion, so the *previous* mapped - // command's timestamp is a usable startTime for the next one. let prevEndMs = 0 let callCounter = 0 for (const cmd of commands) { @@ -144,22 +155,47 @@ function buildActionEvents( } callCounter++ const callId = `call@${callCounter}` - // +1ms minimum duration guarantees endTime > startTime so the viewer - // never sees an `after` whose matching `before` hasn't been parsed yet - // (its action-map lookup crashes on undefined and aborts trace load). - const endMs = Math.max(prevEndMs + 1, cmd.timestamp - wallTime) - const params: Record = Object.fromEntries( - cmd.args.map((a, i) => [String(i), a]) - ) + // Use the command's actual invocation timestamp for the start, falling + // back to the completion timestamp when startTime isn't recorded. + const rawStartMs = (cmd.startTime ?? cmd.timestamp) - wallTime + const rawEndMs = cmd.timestamp - wallTime + // Floor at prevEndMs to prevent visual overlap with previous action. + const startMs = Math.max(prevEndMs, rawStartMs) + // +1ms minimum duration so the viewer never sees an `after` whose + // matching `before` hasn't been parsed yet. + const endMs = Math.max(startMs + 1, rawEndMs) + const rawArgs = cmd.args as unknown[] + let params: Record + const isValueMethod = FILL_METHODS.has(action.method) + if (action.class === 'Element' && isValueMethod && rawArgs.length >= 2) { + params = { selector: rawArgs[0], value: rawArgs[1] } + } else if ( + action.class === 'Element' && + isValueMethod && + rawArgs.length === 1 + ) { + params = { value: rawArgs[0] } + } else if ( + action.class === 'Element' && + rawArgs.length === 1 && + typeof rawArgs[0] === 'string' + ) { + params = { selector: rawArgs[0] } + } else if (rawArgs.length === 1 && typeof rawArgs[0] === 'string') { + params = { url: rawArgs[0] } + } else { + params = Object.fromEntries(rawArgs.map((a, i) => [String(i), a])) + } events.push({ type: 'before', callId, - startTime: prevEndMs, + startTime: startMs, class: action.class, method: action.method, pageId, params, - title: formatActionTitle(action, cmd.args, params) + title: formatActionTitle(action, cmd.args, params), + apiName: `${action.class.toLowerCase()}.${action.method}` }) const afterEvent: AfterEvent = { type: 'after', @@ -176,11 +212,25 @@ function buildActionEvents( return events } -function buildNetworkNdjson(requests: NetworkRequest[]): Buffer { +function buildNetworkNdjson( + requests: NetworkRequest[], + wallTime: number, + pageId: string +): Buffer { if (!requests.length) { return Buffer.alloc(0) } - const lines = requests.map((r) => JSON.stringify(networkRequestToHar(r))) + const lines = requests.map((r) => { + const entry = networkRequestToHar(r) as unknown as Record + entry.snapshot = { + ...(entry.snapshot as Record), + // Monotonic offset so the viewer positions bars on the timeline. + _monotonicTime: Math.max(0, r.timestamp - wallTime), + // Browsing context ID so the viewer associates requests with the page. + _frameref: pageId + } + return JSON.stringify(entry) + }) return Buffer.from(lines.join('\n'), 'utf8') } @@ -199,13 +249,13 @@ function buildSnapshotResources( } if (snap.elements && snap.elements.length) { out.push({ - resourceName: `elements-${base}.json`, + resourceName: `${base}-elements.json`, data: Buffer.from(JSON.stringify(snap.elements), 'utf8') }) } if (snap.snapshotText) { out.push({ - resourceName: `snapshot-${base}.txt`, + resourceName: `${base}-snapshot.txt`, data: Buffer.from(snap.snapshotText, 'utf8') }) } @@ -232,7 +282,10 @@ function buildScreencastFrames( timestamp: Math.max(0, s.timestamp - wallTime) } if (s.elements && s.elements.length) { - frame.elements = `elements-${base}.json` + frame.elements = `${base}-elements.json` + } + if (s.snapshotText) { + frame.snapshot = `${base}-snapshot.txt` } return frame }) @@ -280,15 +333,65 @@ function compareEvents(a: TraceEvent, b: TraceEvent): number { return dt !== 0 ? dt : eventOrder(a) - eventOrder(b) } +/** + * Generate a human/LLM-readable Markdown transcript from captured commands. + */ +function generateTranscript( + commands: CommandLog[], + startWallTime: number, + title?: string +): string { + const wallTimeISO = new Date(startWallTime).toISOString() + const lines: string[] = [`# ${title ?? 'Session'} — ${wallTimeISO}`, ''] + + const captured: { entry: CommandLog; action: TraceAction }[] = [] + for (const c of commands) { + const action = mapCommandToAction(String(c.command)) + if (action) { + captured.push({ entry: c, action }) + } + } + + captured.forEach(({ entry, action }, idx) => { + const label = formatActionTitle(action, entry.args as unknown[]) + + const rawArgs = entry.args as unknown[] + const parts: string[] = [`${idx + 1}. ${label}`] + + if (FILL_METHODS.has(action.method) && rawArgs) { + const valueIdx = rawArgs.length >= 2 ? 1 : 0 + if (rawArgs[valueIdx] !== undefined) { + parts.push(`value="${String(rawArgs[valueIdx])}"`) + } + } + + if (entry.error) { + const msg = + typeof entry.error === 'object' && 'message' in entry.error + ? (entry.error as { message: string }).message + : String(entry.error) + parts.push(`ERROR: ${msg}`) + } + + lines.push(parts.join(' ')) + }) + + return lines.join('\n') +} + interface TraceBundle { traceNdjson: string networkNdjson: Buffer + transcriptMd: string resources: TraceZipResource[] } function buildTraceBundle( trace: TraceLog, - opts: { sessionId?: string; wallTimeOverride?: number } = {} + opts: { + sessionId?: string + wallTimeOverride?: number + } = {} ): TraceBundle { // wallTime anchors monotonic offsets at the first captured command so // subsequent actions render at positive deltas in the trace viewer. @@ -299,34 +402,87 @@ function buildTraceBundle( const pageId = `page@${idPrefix}` const viewport = trace.metadata.viewport ?? { width: 1280, height: 720 } const snapshots = trace.actionSnapshots ?? [] - const events: TraceEvent[] = [ - buildContextOptions(trace, contextId, wallTime), - ...buildScreencastFrames(snapshots, pageId, wallTime, viewport), + const events: TraceEvent[] = [buildContextOptions(trace, contextId, wallTime)] + + // Emit initial screencast-frame (timestamp=0) using the first snapshot's + // resources so trace viewers show the page state before any interaction. + const firstSnap = snapshots.find((s) => s.screenshot) + if (firstSnap) { + const base = `${pageId}-${firstSnap.timestamp}` + const initFrame: ScreencastFrameEvent = { + type: 'screencast-frame', + pageId, + sha1: `${base}.jpeg`, + width: viewport.width, + height: viewport.height, + timestamp: 0 + } + if (firstSnap.elements && firstSnap.elements.length) { + initFrame.elements = `${base}-elements.json` + } + if (firstSnap.snapshotText) { + initFrame.snapshot = `${base}-snapshot.txt` + } + events.push(initFrame) + } + + events.push( + // Skip the first snapshot in buildScreencastFrames — it was already emitted + // as the initial t=0 frame above. + ...buildScreencastFrames( + firstSnap ? snapshots.filter((s) => s !== firstSnap) : snapshots, + pageId, + wallTime, + viewport + ), ...buildActionEvents(trace.commands, pageId, wallTime) - ].sort(compareEvents) + ) + events.sort(compareEvents) + const caps = trace.metadata.capabilities as + | Record + | undefined + const ctxBName = resolveContextNaming(caps).title return { - traceNdjson: events.map((e) => JSON.stringify(e)).join('\n'), - networkNdjson: buildNetworkNdjson(trace.networkRequests), + traceNdjson: events.map((e) => JSON.stringify(e)).join('\n') + '\n', + networkNdjson: buildNetworkNdjson(trace.networkRequests, wallTime, pageId), + transcriptMd: generateTranscript(trace.commands, wallTime, ctxBName), resources: buildSnapshotResources(snapshots, pageId) } } export async function exportTraceZip( trace: TraceLog, - opts: { sessionId?: string; wallTimeOverride?: number } = {} + opts: { + sessionId?: string + wallTimeOverride?: number + } = {} ): Promise { - return buildTraceZip(buildTraceBundle(trace, opts)) + const bundle = buildTraceBundle(trace, opts) + return buildTraceZip({ + traceNdjson: bundle.traceNdjson, + networkNdjson: bundle.networkNdjson, + resources: bundle.resources, + transcriptMd: bundle.transcriptMd + }) } async function exportTraceDirectory( trace: TraceLog, targetDir: string, - opts: { sessionId?: string; wallTimeOverride?: number } = {} + opts: { + sessionId?: string + wallTimeOverride?: number + } = {} ): Promise { const bundle = buildTraceBundle(trace, opts) await fs.mkdir(path.join(targetDir, 'resources'), { recursive: true }) await Promise.all([ fs.writeFile(path.join(targetDir, 'trace.trace'), bundle.traceNdjson), + fs.writeFile( + path.join(targetDir, 'transcript.md'), + bundle.transcriptMd, + 'utf8' + ), bundle.networkNdjson.length ? fs.writeFile( path.join(targetDir, 'trace.network'), @@ -348,6 +504,7 @@ export interface TraceCapturer { commandsLog: CommandLog[] sources: Map metadata?: Metadata + startWallTime?: number } export interface WriteTraceZipOptions { @@ -393,13 +550,17 @@ export async function writeTraceZip( ...(actionSnapshots.length ? { actionSnapshots } : {}) } await fs.mkdir(opts.outputDir, { recursive: true }) + const exportOpts = { + sessionId: opts.sessionId, + wallTimeOverride: capturer.startWallTime + } if (opts.format === 'ndjson-directory') { const dir = path.join(opts.outputDir, `trace-${opts.sessionId}`) await fs.mkdir(dir, { recursive: true }) - await exportTraceDirectory(traceLog, dir, { sessionId: opts.sessionId }) + await exportTraceDirectory(traceLog, dir, exportOpts) return dir } - const zip = await exportTraceZip(traceLog, { sessionId: opts.sessionId }) + const zip = await exportTraceZip(traceLog, exportOpts) const zipPath = path.join(opts.outputDir, `trace-${opts.sessionId}.zip`) await fs.writeFile(zipPath, zip) return zipPath diff --git a/packages/core/src/trace-har.ts b/packages/core/src/trace-har.ts index 565053f..daf7f91 100644 --- a/packages/core/src/trace-har.ts +++ b/packages/core/src/trace-har.ts @@ -91,9 +91,9 @@ export function networkRequestToHar( }, cache: {}, timings: { - send: -1, + send: 0, wait: Math.max(0, duration), - receive: -1 + receive: 0 } } } diff --git a/packages/core/src/trace-zip-writer.ts b/packages/core/src/trace-zip-writer.ts index f3dfac6..13849f1 100644 --- a/packages/core/src/trace-zip-writer.ts +++ b/packages/core/src/trace-zip-writer.ts @@ -14,6 +14,8 @@ export interface TraceZipInputs { traceNdjson: string /** NDJSON HAR resource-snapshot entries. Empty buffer when omitted. */ networkNdjson: Buffer + /** Human/LLM-readable Markdown transcript. */ + transcriptMd?: string /** Files written under `resources/` — typically screenshots + element snapshots. */ resources: TraceZipResource[] } @@ -23,6 +25,12 @@ export function buildTraceZip(inputs: TraceZipInputs): Promise { const zipFile = new yazl.ZipFile() zipFile.addBuffer(Buffer.from(inputs.traceNdjson, 'utf8'), 'trace.trace') zipFile.addBuffer(inputs.networkNdjson, 'trace.network') + if (inputs.transcriptMd) { + zipFile.addBuffer( + Buffer.from(inputs.transcriptMd, 'utf8'), + 'transcript.md' + ) + } for (const resource of inputs.resources) { zipFile.addBuffer(resource.data, `resources/${resource.resourceName}`) } diff --git a/packages/service/package.json b/packages/service/package.json index b00eb46..b532805 100644 --- a/packages/service/package.json +++ b/packages/service/package.json @@ -31,24 +31,27 @@ "scripts": { "dev": "vite build --watch", "build": "tsc && vite build", - "lint": "eslint .", + "lint": "eslint . --fix", "prepublishOnly": "pnpm build" }, "dependencies": { "@babel/parser": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", + "@types/yazl": "^2.4.6", "@wdio/devtools-backend": "workspace:^", "@wdio/devtools-script": "workspace:^", "@wdio/elements": "workspace:^", "@wdio/logger": "9.18.0", "@wdio/reporter": "9.27.2", "@wdio/types": "9.27.2", + "@xmldom/xmldom": "^0.9.8", "fluent-ffmpeg": "^2.1.3", "import-meta-resolve": "^4.2.0", "stack-trace": "^1.0.0", "stacktrace-parser": "^0.1.11", "ws": "^8.21.0", + "xpath": "^0.0.34", "yazl": "^2.5.1" }, "license": "MIT", diff --git a/packages/service/src/action-snapshot.ts b/packages/service/src/action-snapshot.ts index 6512f33..3311587 100644 --- a/packages/service/src/action-snapshot.ts +++ b/packages/service/src/action-snapshot.ts @@ -10,6 +10,7 @@ import { captureActionSnapshot as coreCapture } from '@wdio/devtools-core' import type { ActionSnapshot } from '@wdio/devtools-shared' +import { isNativeMobile, mobilePlatform } from './mobile.js' function reviveScript(src: string): () => unknown { // `src` from core/element-scripts is already a self-invoking IIFE @@ -22,11 +23,22 @@ export function captureActionSnapshot( browser: WebdriverIO.Browser, command: string ): Promise { + const native = isNativeMobile(browser) return coreCapture({ command, - runScript: (src) => browser.execute(reviveScript(src)), + runScript: native ? undefined : (src) => browser.execute(reviveScript(src)), takeScreenshot: () => browser.takeScreenshot().catch(() => undefined), - getUrl: () => browser.getUrl().catch(() => undefined), - getTitle: () => browser.getTitle().catch(() => undefined) + // url/title are browser-only concepts — they fail with "Method has not + // yet been implemented" on native mobile, costing a round-trip each. + getUrl: native ? undefined : () => browser.getUrl().catch(() => undefined), + getTitle: native + ? undefined + : () => browser.getTitle().catch(() => undefined), + // On native mobile, use page-source XML to produce structured element + // data and an AI-readable snapshot (same approach as @wdio/elements). + getPageSource: native + ? () => browser.getPageSource().catch(() => undefined) + : undefined, + platform: native ? mobilePlatform(browser) : undefined }) } diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 2984500..43cd694 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -32,6 +32,7 @@ import { type ScreencastOptions } from './types.js' import { INTERNAL_COMMANDS, CONTEXT_CHANGE_COMMANDS } from './constants.js' +import { isNativeMobile } from './mobile.js' export * from './types.js' export const launcher = DevToolsAppLauncher @@ -41,6 +42,7 @@ const log = logger('@wdio/devtools-service') type CommandFrame = { command: string callSource?: string + startTimestamp: number } export { setupForDevtools } from './standalone.js' @@ -55,7 +57,6 @@ export default class DevToolsHookService implements Services.ServiceInstance { #screencastOptions?: ScreencastOptions #options: ServiceOptions #actionSnapshots: ActionSnapshot[] = [] - #snapshotCaptures: Promise[] = [] constructor(serviceOptions: ServiceOptions = {}) { this.#options = serviceOptions @@ -73,9 +74,6 @@ export default class DevToolsHookService implements Services.ServiceInstance { */ #commandStack: CommandFrame[] = [] - // This is used to capture the last command signature to avoid duplicate captures - #lastCommandSig: string | null = null - /** * allows to define the type of data being captured to hint the * devtools app which data to expect @@ -103,14 +101,18 @@ export default class DevToolsHookService implements Services.ServiceInstance { ) /** - * Block until injection completes BEFORE any test commands + * Block until injection completes BEFORE any test commands. + * Skip on native mobile — Appium sessions don't support WebDriver BiDi + * and the injection always fails with SevereServiceError. */ - try { - await this.#injectScriptSync(browser) - } catch (err) { - log.error( - `Failed to inject script at session start: ${errorMessage(err)}` - ) + if (!isNativeMobile(browser)) { + try { + await this.#injectScriptSync(browser) + } catch (err) { + log.error( + `Failed to inject script at session start: ${errorMessage(err)}` + ) + } } /** @@ -124,18 +126,21 @@ export default class DevToolsHookService implements Services.ServiceInstance { } /** - * propagate session metadata at the beginning of the session + * propagate session metadata at the beginning of the session. + * Skip on mobile — Appium sessions don't have a browser DOM context. */ - browser - .execute(() => window.visualViewport) - .then((viewport) => - this.#sessionCapturer.sendUpstream('metadata', { - viewport: viewport || undefined, - type: this.captureType, - options: browser.options, - capabilities: browser.capabilities as Capabilities.W3CCapabilities - }) - ) + if (!isNativeMobile(browser)) { + browser + .execute(() => window.visualViewport) + .then((viewport) => + this.#sessionCapturer.sendUpstream('metadata', { + viewport: viewport || undefined, + type: this.captureType, + options: browser.options, + capabilities: browser.capabilities as Capabilities.W3CCapabilities + }) + ) + } } // The method signature is corrected to use W3CCapabilities @@ -199,8 +204,38 @@ export default class DevToolsHookService implements Services.ServiceInstance { this.resetStack() } + async afterScenario() { + await this.#finalizePerScenario() + } + + async afterTest() { + await this.#finalizePerScenario() + } + + async #finalizePerScenario() { + if (this.#options.mode !== 'trace' || !this.#browser) { + return + } + const stamp = this.#lastActionTimestamp() + const snap = await captureActionSnapshot(this.#browser, '__final__') + if (snap) { + snap.timestamp = stamp + this.#actionSnapshots.push(snap) + } + } + + #lastActionTimestamp(): number { + const commands = this.#sessionCapturer.commandsLog + for (let i = commands.length - 1; i >= 0; i--) { + const cmd = commands[i]! + if (mapCommandToAction(cmd.command)) { + return cmd.timestamp + } + } + return Date.now() + } + private resetStack() { - this.#lastCommandSig = null this.#commandStack = [] } @@ -230,16 +265,18 @@ export default class DevToolsHookService implements Services.ServiceInstance { #pushTopLevelCommandFrame( command: string, - args: string[], callSource: string | undefined ): void { if (INTERNAL_COMMANDS.includes(command)) { return } - const cmdSig = JSON.stringify({ command, args, src: callSource }) - if (this.#lastCommandSig !== cmdSig) { - this.#commandStack.push({ command, callSource }) - this.#lastCommandSig = cmdSig + const top = this.#commandStack[this.#commandStack.length - 1] + if (!top || top.command !== command || top.callSource !== callSource) { + this.#commandStack.push({ + command, + callSource, + startTimestamp: Date.now() + }) } } @@ -266,13 +303,27 @@ export default class DevToolsHookService implements Services.ServiceInstance { if (source && this.#commandStack.length === 0) { this.#pushTopLevelCommandFrame( command, - args, this.#resolveCallSourceFromFrame(source) ) + + // Pre-action capture: state BEFORE this action executes. Will be + // stamped at the previous action's end time (or 0 for the first). + if ( + this.#options.mode === 'trace' && + this.#browser && + mapCommandToAction(command) && + !INTERNAL_COMMANDS.includes(command) + ) { + const snap = await captureActionSnapshot(this.#browser, command) + if (snap) { + snap.timestamp = this.#lastActionTimestamp() + this.#actionSnapshots.push(snap) + } + } } } - afterCommand( + async afterCommand( command: keyof WebDriverCommands, args: unknown[], result: unknown, @@ -290,28 +341,15 @@ export default class DevToolsHookService implements Services.ServiceInstance { if (frame?.command === command) { this.#commandStack.pop() if (this.#browser) { - const captured = this.#sessionCapturer.afterCommand( + const captured = await this.#sessionCapturer.afterCommand( this.#browser, command, args, result, error, - frame.callSource + frame.callSource, + frame.startTimestamp ) - if ( - this.#options.mode === 'trace' && - !error && - mapCommandToAction(command) - ) { - const browser = this.#browser - this.#snapshotCaptures.push( - captureActionSnapshot(browser, command).then((snap) => { - if (snap) { - this.#actionSnapshots.push(snap) - } - }) - ) - } return captured } } @@ -334,11 +372,6 @@ export default class DevToolsHookService implements Services.ServiceInstance { // Stop and encode the screencast for the current session. await this.#finalizeScreencast(this.#browser.sessionId) - // Drain in-flight per-action snapshots before writing the trace. - if (this.#snapshotCaptures.length) { - await Promise.allSettled(this.#snapshotCaptures) - } - const outputDir = this.#outputDir const { ...options } = this.#browser.options const traceLog: TraceLog = { @@ -360,13 +393,6 @@ export default class DevToolsHookService implements Services.ServiceInstance { : {}) } - const traceFilePath = path.join( - outputDir, - `wdio-trace-${this.#browser.sessionId}.json` - ) - await fs.writeFile(traceFilePath, JSON.stringify(traceLog)) - log.info(`DevTools trace saved to ${traceFilePath}`) - if (this.#options.mode === 'trace') { const tracePath = await writeTraceZip(this.#sessionCapturer, { outputDir, @@ -378,6 +404,13 @@ export default class DevToolsHookService implements Services.ServiceInstance { format: this.#options.traceFormat }) log.info(`Trace saved to ${tracePath}`) + } else { + const traceFilePath = path.join( + outputDir, + `wdio-trace-${this.#browser.sessionId}.json` + ) + await fs.writeFile(traceFilePath, JSON.stringify(traceLog)) + log.info(`DevTools trace saved to ${traceFilePath}`) } // Clean up console patching diff --git a/packages/service/src/launcher.ts b/packages/service/src/launcher.ts index d874513..19150db 100644 --- a/packages/service/src/launcher.ts +++ b/packages/service/src/launcher.ts @@ -110,6 +110,10 @@ export class DevToolsAppLauncher { } async onPrepare(_: never, caps: ExtendedCapabilities[]) { + if (this.#options.mode === 'trace') { + log.info('Trace mode — skipping backend and Chrome window') + return + } try { this.#captureRerunEnv() const reusePort = process.env[REUSE_ENV.PORT] @@ -136,10 +140,6 @@ export class DevToolsAppLauncher { port, hostname: this.#options.hostname || 'localhost' }) - if (this.#options.mode === 'trace') { - log.info('trace mode: backend started, skipping UI window launch') - return - } this.#browser = await remote({ automationProtocol: 'devtools', capabilities: { diff --git a/packages/service/src/mobile.ts b/packages/service/src/mobile.ts new file mode 100644 index 0000000..ec43695 --- /dev/null +++ b/packages/service/src/mobile.ts @@ -0,0 +1,22 @@ +// Mobile-aware browser — Appium sessions expose `isMobile`, `isAndroid`, +// `isIOS` at runtime. These flags are absent from WDIO's published types +// so we narrow through a single cast here rather than repeating +// `browser as unknown as Record` at every call site. + +type MobileBrowser = WebdriverIO.Browser & { + isMobile?: unknown + isAndroid?: unknown + isIOS?: unknown +} + +export function isNativeMobile(browser: WebdriverIO.Browser): boolean { + const b = browser as MobileBrowser + return Boolean(b.isMobile || b.isAndroid || b.isIOS) +} + +export function mobilePlatform( + browser: WebdriverIO.Browser +): 'android' | 'ios' | undefined { + const b = browser as MobileBrowser + return b.isAndroid ? 'android' : b.isIOS ? 'ios' : undefined +} diff --git a/packages/service/src/session.ts b/packages/service/src/session.ts index 9702643..cc68423 100644 --- a/packages/service/src/session.ts +++ b/packages/service/src/session.ts @@ -8,6 +8,7 @@ import { SevereServiceError } from 'webdriverio' import type { WebDriverCommands } from '@wdio/protocols' import { PAGE_TRANSITION_COMMANDS } from './constants.js' +import { isNativeMobile } from './mobile.js' import { CAPTURE_PERFORMANCE_SCRIPT, LOG_SOURCES, @@ -25,6 +26,10 @@ const log = logger('@wdio/devtools-service:SessionCapturer') export class SessionCapturer extends SessionCapturerBase { #isScriptInjected = false + /** Session start wall time for trace event timestamps. */ + readonly startWallTime = Date.now() + /** Last find-element selector — carried forward to the next element command. */ + #lastSelector: string | undefined #pendingNetworkRequests = new Map< string, { @@ -110,7 +115,8 @@ export class SessionCapturer extends SessionCapturerBase { args: unknown[], result: unknown, error: Error | undefined, - callSource?: string + callSource?: string, + commandStartTime?: number ) { const { sourceFileLocation, absolutePath } = this.#resolveUserStackFrame() const sourceFilePath = absolutePath.split(':')[0] @@ -123,21 +129,86 @@ export class SessionCapturer extends SessionCapturerBase { result, error, timestamp: Date.now(), + startTime: commandStartTime, callSource: callSource ?? absolutePath } - try { - commandLogEntry.screenshot = await browser.takeScreenshot() - } catch (screenshotError) { - log.warn( - `failed to capture screenshot: ${(screenshotError as Error).message}` - ) + if (!isNativeMobile(browser)) { + try { + commandLogEntry.screenshot = await browser.takeScreenshot() + } catch (screenshotError) { + log.warn( + `failed to capture screenshot: ${(screenshotError as Error).message}` + ) + } + } + const cmd = String(command) + + // Track last find-element selector so element commands (click, setValue, …) + // carry a human-readable selector in trace events even though WDIO doesn't + // pass it in their args. + if ( + cmd === '$' || + cmd === '$$' || + cmd === 'findElement' || + cmd === 'findElements' + ) { + const sel = args[0] + if (typeof sel === 'string' && sel.length > 0) { + this.#lastSelector = sel + } } + + // For element-scoped commands without meaningful args, inject the last + // selector so the trace event shows what element was acted upon. + if ( + this.#lastSelector && + (cmd === 'click' || + cmd === 'doubleClick' || + cmd === 'moveTo' || + cmd === 'scrollIntoView' || + cmd === 'touchAction' || + cmd === 'dragAndDrop' || + cmd === 'getText' || + cmd === 'getAttribute' || + cmd === 'clearValue' || + cmd === 'waitForExist' || + cmd === 'waitForDisplayed' || + cmd === 'waitForEnabled' || + cmd === 'waitForClickable') + ) { + const hasNoSelector = + args.length === 0 || + (args.length === 1 && + typeof args[0] === 'object' && + args[0] !== null && + !Array.isArray(args[0]) && + Object.keys(args[0] as object).some((k) => k.startsWith('element-'))) + if (hasNoSelector) { + commandLogEntry.args = [this.#lastSelector] + } + } + + // For setValue / addValue, prepend the last selector so trace params + // carry both {selector, value} like the MCP set_value tool does. + if (this.#lastSelector && (cmd === 'setValue' || cmd === 'addValue')) { + const hasNoSelector = args.length >= 1 && typeof args[0] !== 'object' + if (hasNoSelector) { + commandLogEntry.args = [this.#lastSelector, ...args] + } + } + this.commandsLog.push(commandLogEntry) this.sendUpstream('commands', [commandLogEntry]) // Capture trace + perf on commands that could trigger a page transition. - if (PAGE_TRANSITION_COMMANDS.includes(command)) { - await this.#capturePerformance(browser, commandLogEntry, args) - await this.#captureTrace(browser) + // Skip on native mobile — scripts can't execute in a native app context. + if ( + !isNativeMobile(browser) && + PAGE_TRANSITION_COMMANDS.includes(command) + ) { + await Promise.all([ + this.#capturePerformance(browser, commandLogEntry, args), + this.#captureTrace(browser) + ]) } } @@ -300,26 +371,7 @@ export class SessionCapturer extends SessionCapturerBase { try { const { request, timestamp } = event const requestId = request.request - const requestHeaders: Record = {} - if (request.headers) { - request.headers.forEach( - (h: { - name: string - value: { type?: string; value?: string } | string - }) => { - const name = typeof h.name === 'string' ? h.name.toLowerCase() : '' - const value = - typeof h.value === 'string' - ? h.value - : typeof h.value === 'object' && h.value?.value - ? h.value.value - : '' - if (name) { - requestHeaders[name] = value - } - } - ) - } + const requestHeaders = this.#flattenBidiHeaders(request.headers) this.#pendingNetworkRequests.set(requestId, { url: request.url, @@ -409,8 +461,32 @@ export class SessionCapturer extends SessionCapturerBase { } } - handleNetworkFetchError(event: { request: { request: string } }) { + handleNetworkFetchError(event: { + request: { request: string } + errorText?: string + }) { const requestId = event.request.request - this.#pendingNetworkRequests.delete(requestId) + const pending = this.#pendingNetworkRequests.get(requestId) + if (pending) { + // Emit a HAR resource-snapshot with status 0 and failure text so the + // trace viewer shows the failed request rather than silently dropping it. + this.#pendingNetworkRequests.delete(requestId) + const endTime = performance.now() + const networkRequest: NetworkRequest = { + id: `${Date.now()}-${requestId}`, + url: pending.url, + method: pending.method, + status: 0, + statusText: event.errorText ?? 'Failed', + type: 'other', + timestamp: pending.timestamp, + startTime: pending.startTime, + endTime, + time: endTime - pending.startTime, + requestHeaders: pending.requestHeaders + } + this.networkRequests.push(networkRequest) + this.sendUpstream('networkRequests', [networkRequest]) + } } } diff --git a/packages/service/vite.config.ts b/packages/service/vite.config.ts index e0f04aa..05afcc8 100644 --- a/packages/service/vite.config.ts +++ b/packages/service/vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ if (isPrivateWorkspaceDep) { return false } + // Any relative import (`./foo.js` from top-level, OR `../foo.js` // from a subfolder like utils/) and any absolute path under src/ // must be bundled, not externalized. The `../` case was missing diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0a4bbe6..cb59ab5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -91,6 +91,8 @@ export interface CommandLog { result?: unknown error?: Error | { name: string; message: string; stack?: string } timestamp: number + /** Wall-clock ms when the command was invoked (before execution). */ + startTime?: number callSource?: string screenshot?: string testUid?: string diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d5b6ce..22585ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -499,6 +499,9 @@ importers: '@babel/types': specifier: ^7.29.7 version: 7.29.7 + '@types/yazl': + specifier: ^2.4.6 + version: 2.4.6 '@wdio/devtools-backend': specifier: workspace:^ version: link:../backend @@ -517,6 +520,9 @@ importers: '@wdio/types': specifier: 9.27.2 version: 9.27.2 + '@xmldom/xmldom': + specifier: ^0.9.8 + version: 0.9.10 devtools: specifier: ^8.42.0 version: 8.42.0 @@ -538,6 +544,9 @@ importers: ws: specifier: ^8.21.0 version: 8.21.0 + xpath: + specifier: ^0.0.34 + version: 0.0.34 yazl: specifier: ^2.5.1 version: 2.5.1 @@ -1752,42 +1761,49 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-arm64-musl@1.1.1': resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/nice-linux-ppc64-gnu@1.1.1': resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} engines: {node: '>= 10'} cpu: [ppc64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-riscv64-gnu@1.1.1': resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-s390x-gnu@1.1.1': resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} engines: {node: '>= 10'} cpu: [s390x] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-gnu@1.1.1': resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/nice-linux-x64-musl@1.1.1': resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/nice-openharmony-arm64@1.1.1': resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} @@ -1913,36 +1929,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} @@ -2013,66 +2035,79 @@ packages: resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.0': resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.0': resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.0': resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.0': resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.0': resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.0': resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.0': resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.0': resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.0': resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.0': resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.0': resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.0': resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.61.0': resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} @@ -2194,24 +2229,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -2469,51 +2508,61 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -5054,24 +5103,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}