@rightkit/qa 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -4,7 +4,8 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { addEvidenceCheck, createEvidenceBundle, finishEvidenceBundle, writeEvidenceBundle, } from './evidence.js';
7
+ import { addEvidenceArtifact, addEvidenceCheck, createEvidenceBundle, finishEvidenceBundle, writeEvidenceBundle, } from './evidence.js';
8
+ import { createQaWorkspace, pruneAbandonedWorkspaces } from './workspace.js';
8
9
  import { createWdioConfig, defineRightQaConfig } from './native.js';
9
10
  import { acquireRunOwnership } from './ownership.js';
10
11
  import { runOwnedProcess } from './process.js';
@@ -17,10 +18,13 @@ export async function runNativeQa(rawConfig, options = {}) {
17
18
  throw new Error(`native QA binary not found: ${binary}`);
18
19
  const cacheRoot = path.resolve(app.appRoot, app.evidenceDir ?? '.cache/rightkit-qa');
19
20
  const runId = crypto.randomUUID();
20
- const runRoot = path.join(cacheRoot, 'runs', runId);
21
+ // Earlier crashed runs left their directories behind; sweep them before adding
22
+ // one more, and never touch the run about to start.
23
+ pruneAbandonedWorkspaces(cacheRoot, { keep: [runId] });
24
+ const workspace = createQaWorkspace({ root: cacheRoot, runId, appKey: app.app });
25
+ const runRoot = workspace.root;
21
26
  const generatedConfig = path.join(runRoot, 'wdio.generated.mjs');
22
27
  const evidencePath = path.join(runRoot, 'evidence.json');
23
- fs.mkdirSync(runRoot, { recursive: true });
24
28
  fs.writeFileSync(generatedConfig, [
25
29
  `import { Agent, setGlobalDispatcher } from ${JSON.stringify(resolveUndiciEntry())};`,
26
30
  'setGlobalDispatcher(new Agent());',
@@ -38,15 +42,21 @@ export async function runNativeQa(rawConfig, options = {}) {
38
42
  timeoutMs: options.timeoutMs ?? 5 * 60_000,
39
43
  env: {
40
44
  ...process.env,
41
- RIGHTKIT_QA_NATIVE: '1',
45
+ // The isolated data directory is what makes this a separate instance
46
+ // rather than a second claimant on the installed app's single-instance lock.
47
+ ...workspace.env,
42
48
  RIGHTKIT_QA_HIDDEN: '1',
43
49
  TAURI_WEBDRIVER_PORT: String(options.embeddedPort ?? 4445),
44
50
  },
45
51
  });
46
52
  const passed = result.code === 0;
53
+ // Bind the receipt to the driver log actually produced, so "the run passed"
54
+ // is checkable against bytes rather than taken on trust.
55
+ const logArtifacts = recordRunArtifacts(evidence, path.join(runRoot, 'wdio'));
47
56
  addEvidenceCheck(evidence, {
48
57
  name: 'wdio.run',
49
58
  status: passed ? 'passed' : 'failed',
59
+ artifacts: logArtifacts,
50
60
  details: {
51
61
  code: result.code,
52
62
  signal: result.signal,
@@ -113,6 +123,34 @@ function valueAfter(argv, flag) {
113
123
  const index = argv.indexOf(flag);
114
124
  return index >= 0 ? argv[index + 1] : undefined;
115
125
  }
126
+ /**
127
+ * Hash whatever the driver actually wrote. Missing logs are simply not recorded
128
+ * — a check that references nothing is honest; a check that references a file
129
+ * that was never hashed is refused by addEvidenceCheck.
130
+ */
131
+ function recordRunArtifacts(evidence, logDir) {
132
+ const names = [];
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(logDir);
136
+ }
137
+ catch {
138
+ return names;
139
+ }
140
+ for (const entry of entries.sort()) {
141
+ const file = path.join(logDir, entry);
142
+ try {
143
+ if (!fs.statSync(file).isFile())
144
+ continue;
145
+ addEvidenceArtifact(evidence, { name: `wdio/${entry}`, path: file });
146
+ names.push(`wdio/${entry}`);
147
+ }
148
+ catch {
149
+ // An unreadable log is not evidence; leaving it unreferenced is the point.
150
+ }
151
+ }
152
+ return names;
153
+ }
116
154
  function tail(value, maxLength = 4_000) {
117
155
  return value.length <= maxLength ? value : value.slice(-maxLength);
118
156
  }
@@ -1,12 +1,21 @@
1
1
  export type EvidenceCheckStatus = 'passed' | 'failed' | 'skipped';
2
2
  export type EvidenceRunStatus = 'running' | 'passed' | 'failed';
3
+ export interface EvidenceArtifact {
4
+ name: string;
5
+ path: string;
6
+ sha256: string;
7
+ sizeBytes: number;
8
+ recordedAt: string;
9
+ }
3
10
  export interface EvidenceCheck {
4
11
  name: string;
5
12
  status: EvidenceCheckStatus;
6
13
  details?: Record<string, unknown>;
14
+ /** Names of artifacts recorded via addEvidenceArtifact that back this check. */
15
+ artifacts?: string[];
7
16
  }
8
17
  export interface EvidenceBundle {
9
- schemaVersion: 1;
18
+ schemaVersion: 2;
10
19
  app: string;
11
20
  platform: string;
12
21
  runId: string;
@@ -14,6 +23,7 @@ export interface EvidenceBundle {
14
23
  finishedAt?: string;
15
24
  status: EvidenceRunStatus;
16
25
  checks: EvidenceCheck[];
26
+ artifacts: EvidenceArtifact[];
17
27
  }
18
28
  export interface EvidenceBundleInput {
19
29
  app: string;
@@ -22,9 +32,25 @@ export interface EvidenceBundleInput {
22
32
  startedAt?: string;
23
33
  }
24
34
  export declare function createEvidenceBundle(input: EvidenceBundleInput): EvidenceBundle;
35
+ /**
36
+ * Hash a file into the bundle. Reading happens now, not at write time, so a file
37
+ * that is later replaced or deleted cannot masquerade as the one that was seen.
38
+ */
39
+ export declare function addEvidenceArtifact(bundle: EvidenceBundle, artifact: {
40
+ name: string;
41
+ path: string;
42
+ }, now?: Date): EvidenceArtifact;
25
43
  export declare function addEvidenceCheck(bundle: EvidenceBundle, check: EvidenceCheck): void;
26
44
  export declare function finishEvidenceBundle(bundle: EvidenceBundle, result: {
27
45
  status: Exclude<EvidenceRunStatus, 'running'>;
28
46
  finishedAt?: string;
29
47
  }): void;
48
+ /**
49
+ * Re-hash every recorded artifact. Returns the artifacts whose bytes no longer
50
+ * match — empty means the receipt still describes what is on disk.
51
+ */
52
+ export declare function verifyEvidenceArtifacts(bundle: EvidenceBundle): Array<{
53
+ name: string;
54
+ reason: string;
55
+ }>;
30
56
  export declare function writeEvidenceBundle(filePath: string, bundle: EvidenceBundle): void;
package/dist/evidence.js CHANGED
@@ -1,30 +1,107 @@
1
+ // A receipt that names a file without hashing it is a claim, not evidence.
2
+ //
3
+ // The previous bundle recorded check names, statuses, and free-form details, then
4
+ // declared the run passed. Nothing tied those claims to the artifacts they were
5
+ // about: a screenshot could be regenerated, truncated, or never written at all
6
+ // and the receipt read exactly the same. "Signature verified" and "verified the
7
+ // signature of THESE bytes" are different statements, and only the second one
8
+ // survives someone asking later.
9
+ //
10
+ // So every artifact a check refers to is hashed at the moment it is recorded, a
11
+ // check may only reference artifacts that were hashed, and the bundle can be
12
+ // re-verified against the filesystem afterwards.
13
+ import crypto from 'node:crypto';
1
14
  import fs from 'node:fs';
2
15
  import path from 'node:path';
3
16
  export function createEvidenceBundle(input) {
4
17
  return {
5
- schemaVersion: 1,
18
+ schemaVersion: 2,
6
19
  app: requireText(input.app, 'app'),
7
20
  platform: requireText(input.platform, 'platform'),
8
21
  runId: requireText(input.runId, 'runId'),
9
22
  startedAt: input.startedAt ?? new Date().toISOString(),
10
23
  status: 'running',
11
24
  checks: [],
25
+ artifacts: [],
12
26
  };
13
27
  }
28
+ /**
29
+ * Hash a file into the bundle. Reading happens now, not at write time, so a file
30
+ * that is later replaced or deleted cannot masquerade as the one that was seen.
31
+ */
32
+ export function addEvidenceArtifact(bundle, artifact, now = new Date()) {
33
+ requireRunning(bundle);
34
+ const name = requireText(artifact.name, 'artifact name');
35
+ if (bundle.artifacts.some((existing) => existing.name === name)) {
36
+ throw new Error(`QA evidence already records an artifact named ${name}`);
37
+ }
38
+ const resolved = path.resolve(requireText(artifact.path, 'artifact path'));
39
+ let bytes;
40
+ try {
41
+ bytes = fs.readFileSync(resolved);
42
+ }
43
+ catch (error) {
44
+ throw new Error(`cannot record QA evidence artifact ${name}: ${resolved} is unreadable (${error.message})`);
45
+ }
46
+ const recorded = {
47
+ name,
48
+ path: resolved,
49
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
50
+ sizeBytes: bytes.length,
51
+ recordedAt: now.toISOString(),
52
+ };
53
+ bundle.artifacts.push(recorded);
54
+ return recorded;
55
+ }
14
56
  export function addEvidenceCheck(bundle, check) {
15
- if (bundle.status !== 'running') {
16
- throw new Error('cannot add a QA evidence check after the run has finished');
57
+ requireRunning(bundle);
58
+ const name = requireText(check.name, 'check name');
59
+ const artifacts = check.artifacts ?? [];
60
+ const unknown = artifacts.filter((reference) => !bundle.artifacts.some((item) => item.name === reference));
61
+ if (unknown.length > 0) {
62
+ throw new Error(`QA check ${name} references artifacts that were never hashed: ${unknown.join(', ')}. `
63
+ + 'Record them with addEvidenceArtifact so the receipt is bound to real bytes.');
17
64
  }
18
- bundle.checks.push({ ...check, name: requireText(check.name, 'check name') });
65
+ bundle.checks.push({ ...check, name, ...(artifacts.length > 0 ? { artifacts: [...artifacts] } : {}) });
19
66
  }
20
67
  export function finishEvidenceBundle(bundle, result) {
21
68
  bundle.status = result.status;
22
69
  bundle.finishedAt = result.finishedAt ?? new Date().toISOString();
23
70
  }
71
+ /**
72
+ * Re-hash every recorded artifact. Returns the artifacts whose bytes no longer
73
+ * match — empty means the receipt still describes what is on disk.
74
+ */
75
+ export function verifyEvidenceArtifacts(bundle) {
76
+ const drift = [];
77
+ for (const artifact of bundle.artifacts) {
78
+ let bytes;
79
+ try {
80
+ bytes = fs.readFileSync(artifact.path);
81
+ }
82
+ catch {
83
+ drift.push({ name: artifact.name, reason: `missing at ${artifact.path}` });
84
+ continue;
85
+ }
86
+ const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
87
+ if (sha256 !== artifact.sha256) {
88
+ drift.push({ name: artifact.name, reason: `sha256 ${sha256} does not match recorded ${artifact.sha256}` });
89
+ }
90
+ }
91
+ return drift;
92
+ }
24
93
  export function writeEvidenceBundle(filePath, bundle) {
25
94
  if (bundle.status === 'running' || !bundle.finishedAt) {
26
95
  throw new Error('refusing to write unfinished QA evidence');
27
96
  }
97
+ // A passing receipt is the one people trust without reading, so it is the one
98
+ // whose artifacts must still be intact at write time.
99
+ if (bundle.status === 'passed') {
100
+ const drift = verifyEvidenceArtifacts(bundle);
101
+ if (drift.length > 0) {
102
+ throw new Error(`refusing to write a passing QA receipt whose evidence changed: ${drift.map((item) => `${item.name} (${item.reason})`).join('; ')}`);
103
+ }
104
+ }
28
105
  const resolved = path.resolve(filePath);
29
106
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
30
107
  const temporary = `${resolved}.tmp-${process.pid}`;
@@ -36,8 +113,12 @@ export function writeEvidenceBundle(filePath, bundle) {
36
113
  fs.rmSync(temporary, { force: true });
37
114
  }
38
115
  }
116
+ function requireRunning(bundle) {
117
+ if (bundle.status !== 'running')
118
+ throw new Error('cannot modify a QA evidence bundle after the run has finished');
119
+ }
39
120
  function requireText(value, name) {
40
- const normalized = value.trim();
121
+ const normalized = String(value ?? '').trim();
41
122
  if (!normalized)
42
123
  throw new Error(`${name} is required`);
43
124
  return normalized;
@@ -0,0 +1,39 @@
1
+ export interface QaFrameBrowser {
2
+ execute<T>(script: string, ...args: unknown[]): Promise<T>;
3
+ switchToFrame(target: number | null | unknown): Promise<unknown>;
4
+ switchToParentFrame(): Promise<unknown>;
5
+ }
6
+ /** Index path from the top document to a nested frame (`[]` is the top document). */
7
+ export type FramePath = readonly number[];
8
+ export interface FrameMatch {
9
+ framePath: FramePath;
10
+ count: number;
11
+ }
12
+ /**
13
+ * Run `body` with the driver focused on `framePath`, then return to the top
14
+ * document. Restoration happens in a finally block: a spec that throws mid-frame
15
+ * used to leave every later selector silently scoped to that iframe.
16
+ */
17
+ export declare function withFrame<T>(browser: QaFrameBrowser, framePath: FramePath, body: () => Promise<T>): Promise<T>;
18
+ export declare function countMatchesScript(selector: string): string;
19
+ export declare function countFramesScript(): string;
20
+ /**
21
+ * Search every document — top level and nested frames — and report each one that
22
+ * matches. Callers get the ambiguity rather than a single arbitrary winner.
23
+ */
24
+ export declare function findAcrossFrames(browser: QaFrameBrowser, selector: string, { maxDepth }?: {
25
+ maxDepth?: number;
26
+ }): Promise<FrameMatch[]>;
27
+ /**
28
+ * Resolve the one document a selector is allowed to match. Ambiguity fails with
29
+ * both locations named, because "it matched somewhere" is what let chrome-level
30
+ * matches stand in for content-level ones.
31
+ */
32
+ export declare function resolveUniqueFrame(browser: QaFrameBrowser, selector: string, options?: {
33
+ maxDepth?: number;
34
+ }): Promise<FramePath>;
35
+ /** Run `body` inside the single document that matches `selector`. */
36
+ export declare function withContentFrame<T>(browser: QaFrameBrowser, selector: string, body: (framePath: FramePath) => Promise<T>, options?: {
37
+ maxDepth?: number;
38
+ }): Promise<T>;
39
+ export declare function describeFrame(framePath: FramePath): string;
package/dist/frames.js ADDED
@@ -0,0 +1,90 @@
1
+ // Selectors must name a document, not just an element.
2
+ //
3
+ // The app renders untrusted document content inside iframes. A selector run
4
+ // against the top-level document therefore searches the CHROME, not the content
5
+ // — and because the chrome often contains a same-named element (a toolbar title,
6
+ // a placeholder, a template node), the query succeeds and the assertion passes
7
+ // while never having looked at the thing under test.
8
+ //
9
+ // Two rules encoded here:
10
+ // - entering a frame is always paired with leaving it, even when the body throws;
11
+ // - a selector that matches in more than one document is an error, not a
12
+ // coin-flip resolved by document order.
13
+ /**
14
+ * Run `body` with the driver focused on `framePath`, then return to the top
15
+ * document. Restoration happens in a finally block: a spec that throws mid-frame
16
+ * used to leave every later selector silently scoped to that iframe.
17
+ */
18
+ export async function withFrame(browser, framePath, body) {
19
+ await enterFrame(browser, framePath);
20
+ try {
21
+ return await body();
22
+ }
23
+ finally {
24
+ await browser.switchToFrame(null);
25
+ }
26
+ }
27
+ async function enterFrame(browser, framePath) {
28
+ await browser.switchToFrame(null);
29
+ for (const index of framePath) {
30
+ if (!Number.isInteger(index) || index < 0)
31
+ throw new Error(`invalid frame index in path: ${JSON.stringify(framePath)}`);
32
+ await browser.switchToFrame(index);
33
+ }
34
+ }
35
+ export function countMatchesScript(selector) {
36
+ return `return document.querySelectorAll(${JSON.stringify(selector)}).length;`;
37
+ }
38
+ export function countFramesScript() {
39
+ return 'return window.frames.length;';
40
+ }
41
+ /**
42
+ * Search every document — top level and nested frames — and report each one that
43
+ * matches. Callers get the ambiguity rather than a single arbitrary winner.
44
+ */
45
+ export async function findAcrossFrames(browser, selector, { maxDepth = 4 } = {}) {
46
+ const matches = [];
47
+ async function visit(framePath, depth) {
48
+ await enterFrame(browser, framePath);
49
+ const count = Number(await browser.execute(countMatchesScript(selector)));
50
+ if (count > 0)
51
+ matches.push({ framePath: [...framePath], count });
52
+ if (depth >= maxDepth)
53
+ return;
54
+ const frameCount = Number(await browser.execute(countFramesScript()));
55
+ for (let index = 0; index < frameCount; index += 1) {
56
+ await visit([...framePath, index], depth + 1);
57
+ }
58
+ }
59
+ try {
60
+ await visit([], 0);
61
+ }
62
+ finally {
63
+ await browser.switchToFrame(null);
64
+ }
65
+ return matches;
66
+ }
67
+ /**
68
+ * Resolve the one document a selector is allowed to match. Ambiguity fails with
69
+ * both locations named, because "it matched somewhere" is what let chrome-level
70
+ * matches stand in for content-level ones.
71
+ */
72
+ export async function resolveUniqueFrame(browser, selector, options = {}) {
73
+ const matches = await findAcrossFrames(browser, selector, options);
74
+ if (matches.length === 0)
75
+ throw new Error(`selector matched no document: ${selector}`);
76
+ if (matches.length > 1) {
77
+ const where = matches.map((match) => `${describeFrame(match.framePath)} (${match.count})`).join(', ');
78
+ throw new Error(`selector ${selector} matches in ${matches.length} documents: ${where}. `
79
+ + 'Scope it with withFrame() so the assertion names the document it means.');
80
+ }
81
+ return matches[0].framePath;
82
+ }
83
+ /** Run `body` inside the single document that matches `selector`. */
84
+ export async function withContentFrame(browser, selector, body, options = {}) {
85
+ const framePath = await resolveUniqueFrame(browser, selector, options);
86
+ return withFrame(browser, framePath, () => body(framePath));
87
+ }
88
+ export function describeFrame(framePath) {
89
+ return framePath.length === 0 ? 'top document' : `frame ${framePath.join('.')}`;
90
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  export * from './evidence.js';
2
+ export * from './frames.js';
2
3
  export * from './guard.js';
3
4
  export * from './loopback.js';
4
5
  export * from './native.js';
5
6
  export * from './ownership.js';
7
+ export * from './paint.js';
8
+ export * from './panes.js';
6
9
  export * from './process.js';
10
+ export * from './workspace.js';
package/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  export * from './evidence.js';
2
+ export * from './frames.js';
2
3
  export * from './guard.js';
3
4
  export * from './loopback.js';
4
5
  export * from './native.js';
5
6
  export * from './ownership.js';
7
+ export * from './paint.js';
8
+ export * from './panes.js';
6
9
  export * from './process.js';
10
+ export * from './workspace.js';
@@ -2,18 +2,33 @@ export interface LoopbackClientOptions {
2
2
  baseUrl: string;
3
3
  token: string;
4
4
  pollIntervalMs?: number;
5
+ /** Ceiling for a single HTTP round trip. */
6
+ requestTimeoutMs?: number;
7
+ /** Default ceiling for a queued action, measured from queue to result. */
8
+ actionTimeoutMs?: number;
5
9
  fetchImpl?: typeof fetch;
10
+ newRequestId?: () => string;
6
11
  }
7
12
  export interface ActionResult {
8
13
  id: string;
9
14
  ok: boolean;
10
15
  message?: string;
16
+ requestId?: string;
11
17
  [key: string]: unknown;
12
18
  }
19
+ export interface RequestOptions {
20
+ timeoutMs?: number;
21
+ requestId?: string;
22
+ }
13
23
  export interface LoopbackClient {
14
- request<T = unknown>(method: string, route: string, body?: unknown): Promise<T>;
15
- action(kind: string, payload?: Record<string, unknown>, options?: {
16
- timeoutMs?: number;
17
- }): Promise<ActionResult>;
24
+ request<T = unknown>(method: string, route: string, body?: unknown, options?: RequestOptions): Promise<T>;
25
+ action(kind: string, payload?: Record<string, unknown>, options?: RequestOptions): Promise<ActionResult>;
26
+ }
27
+ export declare class LoopbackTimeoutError extends Error {
28
+ readonly requestId: string | undefined;
29
+ readonly timeoutMs: number;
30
+ constructor(what: string, timeoutMs: number, requestId?: string);
18
31
  }
32
+ export declare const DEFAULT_REQUEST_TIMEOUT_MS = 10000;
33
+ export declare const DEFAULT_ACTION_TIMEOUT_MS = 30000;
19
34
  export declare function createLoopbackClient(options: LoopbackClientOptions): LoopbackClient;
package/dist/loopback.js CHANGED
@@ -1,19 +1,64 @@
1
+ // Control-plane client for the app's loopback QA endpoint.
2
+ //
3
+ // Two properties this file exists to guarantee:
4
+ //
5
+ // 1. NO CALL IS UNBOUNDED. Every fetch carries its own abort deadline, and the
6
+ // action poll's deadline covers the whole queue-and-await cycle. An app that
7
+ // accepts a request and then blocks — a resize that never repaints, a window
8
+ // operation waiting on a message loop that is itself waiting on us — must
9
+ // surface as a timed-out test, not a hung run that a human eventually kills.
10
+ // 2. RESULTS ARE CORRELATED, NOT GUESSED. Every action carries a caller-minted
11
+ // request id which is echoed back in the result. Matching on "the most recent
12
+ // result of this kind" attributes one action's outcome to another whenever
13
+ // two actions of the same kind overlap.
14
+ import { randomUUID } from 'node:crypto';
15
+ export class LoopbackTimeoutError extends Error {
16
+ requestId;
17
+ timeoutMs;
18
+ constructor(what, timeoutMs, requestId) {
19
+ super(`${what} exceeded ${timeoutMs}ms${requestId ? ` (requestId ${requestId})` : ''}`);
20
+ this.name = 'LoopbackTimeoutError';
21
+ this.timeoutMs = timeoutMs;
22
+ this.requestId = requestId;
23
+ }
24
+ }
25
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
26
+ export const DEFAULT_ACTION_TIMEOUT_MS = 30_000;
1
27
  export function createLoopbackClient(options) {
2
28
  const baseUrl = validateLoopbackUrl(options.baseUrl);
3
29
  const token = options.token.trim();
4
30
  if (!token)
5
31
  throw new Error('loopback bearer token is required');
6
32
  const fetchImpl = options.fetchImpl ?? fetch;
7
- const pollIntervalMs = options.pollIntervalMs ?? 250;
8
- async function request(method, route, body) {
9
- const response = await fetchImpl(new URL(route, baseUrl), {
10
- method,
11
- headers: {
12
- authorization: `Bearer ${token}`,
13
- 'content-type': 'application/json',
14
- },
15
- body: body === undefined ? undefined : JSON.stringify(body),
16
- });
33
+ const pollIntervalMs = requirePositive(options.pollIntervalMs ?? 250, 'pollIntervalMs');
34
+ const requestTimeoutMs = requirePositive(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, 'requestTimeoutMs');
35
+ const actionTimeoutMs = requirePositive(options.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS, 'actionTimeoutMs');
36
+ const newRequestId = options.newRequestId ?? (() => randomUUID());
37
+ async function request(method, route, body, requestOptions = {}) {
38
+ const timeoutMs = requirePositive(requestOptions.timeoutMs ?? requestTimeoutMs, 'timeoutMs');
39
+ const controller = new AbortController();
40
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
41
+ let response;
42
+ try {
43
+ response = await fetchImpl(new URL(route, baseUrl), {
44
+ method,
45
+ headers: {
46
+ authorization: `Bearer ${token}`,
47
+ 'content-type': 'application/json',
48
+ ...(requestOptions.requestId ? { 'x-rightkit-qa-request-id': requestOptions.requestId } : {}),
49
+ },
50
+ body: body === undefined ? undefined : JSON.stringify(body),
51
+ signal: controller.signal,
52
+ });
53
+ }
54
+ catch (error) {
55
+ if (controller.signal.aborted)
56
+ throw new LoopbackTimeoutError(`${method} ${route}`, timeoutMs, requestOptions.requestId);
57
+ throw error;
58
+ }
59
+ finally {
60
+ clearTimeout(timer);
61
+ }
17
62
  const text = await response.text();
18
63
  let data = null;
19
64
  try {
@@ -22,39 +67,71 @@ export function createLoopbackClient(options) {
22
67
  catch {
23
68
  data = text;
24
69
  }
25
- if (!response.ok) {
70
+ if (!response.ok)
26
71
  throw new Error(`${method} ${route} failed ${response.status}: ${text}`);
27
- }
28
72
  return data;
29
73
  }
30
74
  async function action(kind, payload = {}, actionOptions = {}) {
31
- const queued = await request('POST', '/app-control/actions', { kind, payload });
75
+ const requestId = actionOptions.requestId ?? newRequestId();
76
+ const timeoutMs = requirePositive(actionOptions.timeoutMs ?? actionTimeoutMs, 'timeoutMs');
77
+ const deadline = Date.now() + timeoutMs;
78
+ const queued = await request('POST', '/app-control/actions', { kind, payload, requestId }, { requestId, timeoutMs: remaining(deadline, timeoutMs, requestId, `app-control ${kind}`) });
32
79
  const id = queued.action?.id;
33
80
  if (!id)
34
- throw new Error(`app-control ${kind} did not return an action id`);
35
- const deadline = Date.now() + (actionOptions.timeoutMs ?? 30_000);
81
+ throw new Error(`app-control ${kind} did not return an action id (requestId ${requestId})`);
36
82
  while (Date.now() < deadline) {
37
- await delay(pollIntervalMs);
38
- const state = await request('GET', '/app-control/state');
39
- const result = state.recentResults?.find((entry) => entry.id === id);
83
+ await delay(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
84
+ let state;
85
+ try {
86
+ state = await request('GET', '/app-control/state', undefined, { requestId, timeoutMs: remaining(deadline, timeoutMs, requestId, `app-control ${kind}`) });
87
+ }
88
+ catch (error) {
89
+ // A poll cut short by the action's own deadline is an action timeout.
90
+ // Reporting the truncated per-request budget instead would name a number
91
+ // the caller never chose and hide the deadline that actually expired.
92
+ if (error instanceof LoopbackTimeoutError && Date.now() >= deadline) {
93
+ throw new LoopbackTimeoutError(`app-control ${kind}`, timeoutMs, requestId);
94
+ }
95
+ throw error;
96
+ }
97
+ // Prefer the request id; fall back to the action id for apps that predate
98
+ // request-id echo. Never fall back to "the newest result".
99
+ const result = state.recentResults?.find((entry) => entry.requestId === requestId)
100
+ ?? state.recentResults?.find((entry) => entry.id === id && entry.requestId === undefined);
40
101
  if (!result)
41
102
  continue;
42
103
  if (!result.ok)
43
- throw new Error(`app-control ${kind} failed: ${result.message ?? 'unknown error'}`);
44
- return result;
104
+ throw new Error(`app-control ${kind} failed: ${result.message ?? 'unknown error'} (requestId ${requestId})`);
105
+ return { ...result, requestId };
45
106
  }
46
- throw new Error(`app-control ${kind} timed out`);
107
+ throw new LoopbackTimeoutError(`app-control ${kind}`, timeoutMs, requestId);
47
108
  }
48
109
  return { request, action };
49
110
  }
111
+ /**
112
+ * Time left before the action's deadline. Exhaustion reports the ORIGINAL budget,
113
+ * not the zero milliseconds remaining — "exceeded 0ms" tells the reader nothing
114
+ * about what was actually allowed.
115
+ */
116
+ function remaining(deadline, budgetMs, requestId, what) {
117
+ const left = deadline - Date.now();
118
+ if (left <= 0)
119
+ throw new LoopbackTimeoutError(what, budgetMs, requestId);
120
+ return left;
121
+ }
50
122
  function validateLoopbackUrl(value) {
51
123
  const url = new URL(value);
52
124
  const host = url.hostname.toLowerCase();
53
- if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(host)) {
125
+ if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]', '::1'].includes(host)) {
54
126
  throw new Error(`QA control endpoint must be an HTTP loopback URL, received ${url.origin}`);
55
127
  }
56
128
  return url;
57
129
  }
130
+ function requirePositive(value, name) {
131
+ if (!Number.isFinite(value) || value <= 0)
132
+ throw new Error(`${name} must be a positive number of milliseconds`);
133
+ return value;
134
+ }
58
135
  async function delay(ms) {
59
136
  await new Promise((resolve) => setTimeout(resolve, ms));
60
137
  }
@@ -0,0 +1,48 @@
1
+ export interface QaBrowser {
2
+ execute<T>(script: string, ...args: unknown[]): Promise<T>;
3
+ executeAsync<T>(script: string, ...args: unknown[]): Promise<T>;
4
+ }
5
+ export interface PaintMetrics {
6
+ requestId: string;
7
+ /** Milliseconds from the caller's mark to the second animation frame. */
8
+ durationMs: number;
9
+ /** Milliseconds spent waiting for DOM mutations to stop. */
10
+ settleMs: number;
11
+ mutations: number;
12
+ /** True when rAF x2 completed; false when the page fell back to a timer. */
13
+ animationFrames: boolean;
14
+ }
15
+ export declare const DEFAULT_SETTLE_MS = 120;
16
+ export declare const DEFAULT_PAINT_TIMEOUT_MS = 10000;
17
+ /**
18
+ * Install the page-side ledger. Idempotent, so specs may call it after every
19
+ * navigation without accumulating observers.
20
+ */
21
+ export declare function installPaintLedgerScript(): string;
22
+ /** Record the start of a measured interaction, keyed by request id. */
23
+ export declare function beginPaintScript(requestId: string, selector?: string): string;
24
+ /**
25
+ * Resolve once the target subtree has been quiet for `settleMs` and two
26
+ * animation frames have elapsed. Falls back to a timer only if the document is
27
+ * hidden, where the browser throttles rAF indefinitely — reported honestly via
28
+ * `animationFrames: false` rather than silently producing a number.
29
+ */
30
+ export declare function awaitPaintScript(requestId: string, settleMs: number, timeoutMs: number): string;
31
+ export interface MeasurePaintOptions {
32
+ requestId: string;
33
+ /** CSS selector for the subtree that should render. Defaults to the document. */
34
+ selector?: string;
35
+ settleMs?: number;
36
+ timeoutMs?: number;
37
+ }
38
+ /**
39
+ * Run `trigger` and return when the resulting render has actually been painted.
40
+ * The mark is installed BEFORE the trigger so no mutation can slip between them.
41
+ */
42
+ export declare function measurePaint(browser: QaBrowser, trigger: () => Promise<unknown>, options: MeasurePaintOptions): Promise<PaintMetrics>;
43
+ /**
44
+ * Assert a paint completed within budget. Separate from measurement so a spec can
45
+ * record a timing without failing on it, and so the failure text carries the
46
+ * evidence rather than just a boolean.
47
+ */
48
+ export declare function assertPaintWithin(metrics: PaintMetrics, budgetMs: number, label: string): PaintMetrics;
package/dist/paint.js ADDED
@@ -0,0 +1,134 @@
1
+ // Measure when the UI was PAINTED, not when the command returned.
2
+ //
3
+ // A driver command resolves as soon as the app acknowledges it. For anything that
4
+ // renders — opening a document, switching tabs, re-laying out after a resize —
5
+ // that acknowledgement happens well before pixels change, so timings taken from
6
+ // it measure the RPC and report it as UI latency. Every such number is wrong in
7
+ // the same direction, which is why they looked plausible.
8
+ //
9
+ // What this module measures instead: from a caller-marked start, wait for DOM
10
+ // mutations in the target subtree to go quiet, then wait two animation frames.
11
+ // The second frame callback runs after the frame containing the layout+paint work
12
+ // has been committed for rendering.
13
+ //
14
+ // Honest limit: rAF x2 fires after commit but before the compositor has
15
+ // necessarily put pixels on the physical display. It is the right signal for
16
+ // catching latency regressions and for ordering ("did it render before we
17
+ // asserted?"). It is NOT proof of pixels; when that is what you need, follow it
18
+ // with a screenshot comparison.
19
+ export const DEFAULT_SETTLE_MS = 120;
20
+ export const DEFAULT_PAINT_TIMEOUT_MS = 10_000;
21
+ const LEDGER = '__rightkitQaPaint';
22
+ /**
23
+ * Install the page-side ledger. Idempotent, so specs may call it after every
24
+ * navigation without accumulating observers.
25
+ */
26
+ export function installPaintLedgerScript() {
27
+ return `
28
+ (function () {
29
+ if (window.${LEDGER}) return true;
30
+ window.${LEDGER} = { marks: new Map() };
31
+ return true;
32
+ })();
33
+ `;
34
+ }
35
+ /** Record the start of a measured interaction, keyed by request id. */
36
+ export function beginPaintScript(requestId, selector) {
37
+ return `
38
+ (function () {
39
+ var ledger = window.${LEDGER};
40
+ if (!ledger) throw new Error('paint ledger is not installed');
41
+ var root = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : 'document.documentElement'};
42
+ if (!root) throw new Error('paint target not found: ' + ${JSON.stringify(selector ?? 'document')});
43
+ var mark = { startedAt: performance.now(), mutations: 0, lastMutationAt: performance.now() };
44
+ mark.observer = new MutationObserver(function (records) {
45
+ mark.mutations += records.length;
46
+ mark.lastMutationAt = performance.now();
47
+ });
48
+ mark.observer.observe(root, { subtree: true, childList: true, characterData: true, attributes: true });
49
+ ledger.marks.set(${JSON.stringify(requestId)}, mark);
50
+ return true;
51
+ })();
52
+ `;
53
+ }
54
+ /**
55
+ * Resolve once the target subtree has been quiet for `settleMs` and two
56
+ * animation frames have elapsed. Falls back to a timer only if the document is
57
+ * hidden, where the browser throttles rAF indefinitely — reported honestly via
58
+ * `animationFrames: false` rather than silently producing a number.
59
+ */
60
+ export function awaitPaintScript(requestId, settleMs, timeoutMs) {
61
+ return `
62
+ (function () {
63
+ var done = arguments[arguments.length - 1];
64
+ var ledger = window.${LEDGER};
65
+ var mark = ledger && ledger.marks.get(${JSON.stringify(requestId)});
66
+ if (!mark) { done({ error: 'no paint mark for request ${requestId}' }); return; }
67
+ var deadline = performance.now() + ${timeoutMs};
68
+
69
+ function finish(animationFrames) {
70
+ mark.observer.disconnect();
71
+ ledger.marks.delete(${JSON.stringify(requestId)});
72
+ done({
73
+ durationMs: performance.now() - mark.startedAt,
74
+ settleMs: mark.lastMutationAt - mark.startedAt,
75
+ mutations: mark.mutations,
76
+ animationFrames: animationFrames,
77
+ });
78
+ }
79
+
80
+ function afterSettle() {
81
+ if (document.hidden) { finish(false); return; }
82
+ requestAnimationFrame(function () { requestAnimationFrame(function () { finish(true); }); });
83
+ }
84
+
85
+ (function poll() {
86
+ var quietFor = performance.now() - mark.lastMutationAt;
87
+ if (quietFor >= ${settleMs}) { afterSettle(); return; }
88
+ if (performance.now() >= deadline) {
89
+ mark.observer.disconnect();
90
+ ledger.marks.delete(${JSON.stringify(requestId)});
91
+ done({ error: 'paint did not settle within ${timeoutMs}ms; ' + mark.mutations + ' mutations, last ' + Math.round(quietFor) + 'ms ago' });
92
+ return;
93
+ }
94
+ setTimeout(poll, 16);
95
+ })();
96
+ })();
97
+ `;
98
+ }
99
+ /**
100
+ * Run `trigger` and return when the resulting render has actually been painted.
101
+ * The mark is installed BEFORE the trigger so no mutation can slip between them.
102
+ */
103
+ export async function measurePaint(browser, trigger, options) {
104
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
105
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PAINT_TIMEOUT_MS;
106
+ await browser.execute(installPaintLedgerScript());
107
+ await browser.execute(beginPaintScript(options.requestId, options.selector));
108
+ await trigger();
109
+ const result = await browser.executeAsync(awaitPaintScript(options.requestId, settleMs, timeoutMs));
110
+ if (!result || result.error) {
111
+ throw new Error(`paint measurement failed for ${options.requestId}: ${result?.error ?? 'no result'}`);
112
+ }
113
+ return {
114
+ requestId: options.requestId,
115
+ durationMs: Number(result.durationMs),
116
+ settleMs: Number(result.settleMs),
117
+ mutations: Number(result.mutations ?? 0),
118
+ animationFrames: result.animationFrames === true,
119
+ };
120
+ }
121
+ /**
122
+ * Assert a paint completed within budget. Separate from measurement so a spec can
123
+ * record a timing without failing on it, and so the failure text carries the
124
+ * evidence rather than just a boolean.
125
+ */
126
+ export function assertPaintWithin(metrics, budgetMs, label) {
127
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0)
128
+ throw new Error('paint budget must be a positive number of milliseconds');
129
+ if (metrics.durationMs > budgetMs) {
130
+ throw new Error(`${label} painted in ${Math.round(metrics.durationMs)}ms, over its ${budgetMs}ms budget `
131
+ + `(${metrics.mutations} DOM mutations, settled after ${Math.round(metrics.settleMs)}ms, requestId ${metrics.requestId})`);
132
+ }
133
+ return metrics;
134
+ }
@@ -0,0 +1,39 @@
1
+ export interface QaGeometryBrowser {
2
+ execute<T>(script: string, ...args: unknown[]): Promise<T>;
3
+ }
4
+ export interface PaneGeometry {
5
+ selector: string;
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ visible: boolean;
11
+ active: boolean;
12
+ matches: number;
13
+ viewport: {
14
+ width: number;
15
+ height: number;
16
+ devicePixelRatio: number;
17
+ };
18
+ }
19
+ export declare function readPaneGeometryScript(selector: string): string;
20
+ export declare function readPaneGeometry(browser: QaGeometryBrowser, selector: string): Promise<PaneGeometry>;
21
+ /**
22
+ * The pane under test must be the visible, active one. Both halves matter: a
23
+ * hidden pane fails "visible", and a background tab that happens to be laid out
24
+ * fails "active".
25
+ */
26
+ export declare function assertActivePane(geometry: PaneGeometry): PaneGeometry;
27
+ export interface ResizeSettleOptions {
28
+ timeoutMs?: number;
29
+ pollIntervalMs?: number;
30
+ /** Consecutive identical readings required before the box counts as settled. */
31
+ stableReads?: number;
32
+ }
33
+ export declare const DEFAULT_RESIZE_TIMEOUT_MS = 5000;
34
+ /**
35
+ * Wait until the ACTIVE pane's box has changed from `before` and then held still.
36
+ * Bounded: a resize the app never applies fails as a timeout naming both boxes,
37
+ * instead of hanging on an RPC that will not answer.
38
+ */
39
+ export declare function waitForPaneResize(browser: QaGeometryBrowser, selector: string, before: Pick<PaneGeometry, 'width' | 'height'>, options?: ResizeSettleOptions): Promise<PaneGeometry>;
package/dist/panes.js ADDED
@@ -0,0 +1,102 @@
1
+ // Assert against the pane the user is actually looking at.
2
+ //
3
+ // Tab and split-view UIs keep inactive panes in the DOM — hidden, zero-sized, or
4
+ // positioned offscreen. A selector that matches "the editor pane" therefore
5
+ // resolves just as happily to a background tab, and the resulting assertion
6
+ // describes a pane nobody can see. Worse, it is stable: it passes every run, for
7
+ // the wrong pane, forever.
8
+ //
9
+ // So geometry is read together with visibility and activeness, and a resize is
10
+ // only considered complete once the ACTIVE pane's box has actually changed and
11
+ // settled. Waiting on the window size alone reports success while the pane
12
+ // inside it is still laid out at its old dimensions.
13
+ export function readPaneGeometryScript(selector) {
14
+ return `
15
+ (function () {
16
+ var nodes = document.querySelectorAll(${JSON.stringify(selector)});
17
+ if (nodes.length === 0) return null;
18
+ // Prefer the node the UI marks as current; fall back to the sole match.
19
+ var chosen = null;
20
+ for (var i = 0; i < nodes.length; i += 1) {
21
+ var node = nodes[i];
22
+ if (node.getAttribute('aria-selected') === 'true'
23
+ || node.getAttribute('aria-current') === 'true'
24
+ || node.dataset.active === 'true'
25
+ || node.classList.contains('active')) { chosen = node; break; }
26
+ }
27
+ if (!chosen) chosen = nodes[0];
28
+ var rect = chosen.getBoundingClientRect();
29
+ var style = getComputedStyle(chosen);
30
+ var hiddenByStyle = style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0;
31
+ var hiddenByAttr = chosen.hasAttribute('hidden') || chosen.getAttribute('aria-hidden') === 'true';
32
+ return {
33
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height,
34
+ visible: !hiddenByStyle && !hiddenByAttr && rect.width > 0 && rect.height > 0,
35
+ active: chosen.getAttribute('aria-selected') === 'true'
36
+ || chosen.getAttribute('aria-current') === 'true'
37
+ || chosen.dataset.active === 'true'
38
+ || chosen.classList.contains('active')
39
+ || (nodes.length === 1 && !hiddenByStyle && !hiddenByAttr),
40
+ matches: nodes.length,
41
+ viewport: { width: window.innerWidth, height: window.innerHeight, devicePixelRatio: window.devicePixelRatio },
42
+ };
43
+ })();
44
+ `;
45
+ }
46
+ export async function readPaneGeometry(browser, selector) {
47
+ const raw = await browser.execute(readPaneGeometryScript(selector));
48
+ if (!raw)
49
+ throw new Error(`pane selector matched no element: ${selector}`);
50
+ return { selector, ...raw };
51
+ }
52
+ /**
53
+ * The pane under test must be the visible, active one. Both halves matter: a
54
+ * hidden pane fails "visible", and a background tab that happens to be laid out
55
+ * fails "active".
56
+ */
57
+ export function assertActivePane(geometry) {
58
+ if (!geometry.visible) {
59
+ throw new Error(`${geometry.selector} is not visible (${geometry.width}x${geometry.height} at ${geometry.x},${geometry.y}); `
60
+ + 'the assertion would describe a pane the user cannot see');
61
+ }
62
+ if (!geometry.active) {
63
+ throw new Error(`${geometry.selector} matched ${geometry.matches} panes and the chosen one is not the active pane; `
64
+ + 'scope the selector to the active tab before asserting');
65
+ }
66
+ return geometry;
67
+ }
68
+ export const DEFAULT_RESIZE_TIMEOUT_MS = 5_000;
69
+ /**
70
+ * Wait until the ACTIVE pane's box has changed from `before` and then held still.
71
+ * Bounded: a resize the app never applies fails as a timeout naming both boxes,
72
+ * instead of hanging on an RPC that will not answer.
73
+ */
74
+ export async function waitForPaneResize(browser, selector, before, options = {}) {
75
+ const timeoutMs = options.timeoutMs ?? DEFAULT_RESIZE_TIMEOUT_MS;
76
+ const pollIntervalMs = options.pollIntervalMs ?? 50;
77
+ const stableReads = options.stableReads ?? 2;
78
+ const deadline = Date.now() + timeoutMs;
79
+ let stable = 0;
80
+ let previous = null;
81
+ let latest = null;
82
+ while (Date.now() < deadline) {
83
+ latest = assertActivePane(await readPaneGeometry(browser, selector));
84
+ const changed = latest.width !== before.width || latest.height !== before.height;
85
+ const held = previous !== null && previous.width === latest.width && previous.height === latest.height;
86
+ if (changed && held) {
87
+ stable += 1;
88
+ if (stable >= stableReads)
89
+ return latest;
90
+ }
91
+ else if (!held) {
92
+ stable = 0;
93
+ }
94
+ previous = latest;
95
+ await delay(pollIntervalMs);
96
+ }
97
+ throw new Error(`${selector} did not settle at a new size within ${timeoutMs}ms: `
98
+ + `before ${before.width}x${before.height}, last ${latest ? `${latest.width}x${latest.height}` : 'unreadable'}`);
99
+ }
100
+ async function delay(ms) {
101
+ await new Promise((resolve) => setTimeout(resolve, ms));
102
+ }
@@ -0,0 +1,46 @@
1
+ export interface QaWorkspace {
2
+ runId: string;
3
+ root: string;
4
+ /** Isolated user-data directory; pass to the app so it takes its own lock. */
5
+ dataDir: string;
6
+ /** Somewhere for screenshots, logs, and other recorded evidence. */
7
+ evidenceDir: string;
8
+ /** Environment overrides that point the app at this workspace. */
9
+ env: Record<string, string>;
10
+ }
11
+ export interface DisposeReport {
12
+ removed: string[];
13
+ retained: Array<{
14
+ path: string;
15
+ reason: string;
16
+ }>;
17
+ }
18
+ export interface CreateQaWorkspaceOptions {
19
+ root: string;
20
+ runId?: string;
21
+ appKey: string;
22
+ }
23
+ export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
24
+ export interface DisposeOptions {
25
+ /** Keep the recorded evidence and remove only the volatile profile. */
26
+ keepEvidence?: boolean;
27
+ attempts?: number;
28
+ retryDelayMs?: number;
29
+ sleep?: (ms: number) => void;
30
+ }
31
+ /**
32
+ * Remove the workspace, retrying through the brief window in which an exiting
33
+ * app still holds its files open. Anything still present afterwards is returned,
34
+ * never silently ignored.
35
+ */
36
+ export declare function disposeQaWorkspace(workspace: QaWorkspace, options?: DisposeOptions): DisposeReport;
37
+ /**
38
+ * Sweep run directories left behind by earlier crashed runs. Bounded to the
39
+ * workspace's own `runs` directory and to entries older than `olderThanMs`, so a
40
+ * concurrent run is never removed out from under itself.
41
+ */
42
+ export declare function pruneAbandonedWorkspaces(root: string, { olderThanMs, now, keep }?: {
43
+ olderThanMs?: number;
44
+ now?: number;
45
+ keep?: string[];
46
+ }): DisposeReport;
@@ -0,0 +1,151 @@
1
+ // An isolated, reliably-removable home for one QA run.
2
+ //
3
+ // Two failures this addresses:
4
+ //
5
+ // Single instance. The apps hold a single-instance lock keyed on their user
6
+ // data directory. A harness that launches the app with the real data directory
7
+ // either loses to an already-running copy (the launch silently hands off to the
8
+ // existing window and the driver attaches to nothing) or steals the user's
9
+ // session. Giving the run its own data directory makes the harness a genuinely
10
+ // separate instance instead of a competitor for the same lock.
11
+ //
12
+ // Cleanup. Best-effort `rm -rf` in a finally block is not rollback. On Windows a
13
+ // file still mapped by an exiting process fails with EBUSY/EPERM for a short
14
+ // window, so a single attempt leaves directories behind — which is exactly the
15
+ // litter found in packages/legal-ui/.cache. Removal retries, and whatever
16
+ // genuinely could not be removed is REPORTED rather than swallowed.
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ export function createQaWorkspace(options) {
21
+ const appKey = requireText(options.appKey, 'appKey');
22
+ const runId = options.runId ?? crypto.randomUUID();
23
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(runId))
24
+ throw new Error(`unsafe QA run id: ${runId}`);
25
+ const root = path.resolve(requireText(options.root, 'root'), 'runs', runId);
26
+ const dataDir = path.join(root, 'data');
27
+ const evidenceDir = path.join(root, 'evidence');
28
+ fs.mkdirSync(dataDir, { recursive: true });
29
+ fs.mkdirSync(evidenceDir, { recursive: true });
30
+ return {
31
+ runId,
32
+ root,
33
+ dataDir,
34
+ evidenceDir,
35
+ env: {
36
+ RIGHTKIT_QA_NATIVE: '1',
37
+ RIGHTKIT_QA_RUN_ID: runId,
38
+ RIGHTKIT_QA_DATA_DIR: dataDir,
39
+ RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
40
+ // Tauri/webview honour these for profile isolation; setting all three keeps
41
+ // the run off the user's real profile regardless of which the app reads.
42
+ XDG_DATA_HOME: dataDir,
43
+ XDG_CONFIG_HOME: dataDir,
44
+ WEBVIEW2_USER_DATA_FOLDER: path.join(dataDir, 'webview2'),
45
+ [`${appKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DATA_DIR`]: dataDir,
46
+ },
47
+ };
48
+ }
49
+ /**
50
+ * Remove the workspace, retrying through the brief window in which an exiting
51
+ * app still holds its files open. Anything still present afterwards is returned,
52
+ * never silently ignored.
53
+ */
54
+ export function disposeQaWorkspace(workspace, options = {}) {
55
+ const attempts = Math.max(1, options.attempts ?? 5);
56
+ const retryDelayMs = options.retryDelayMs ?? 200;
57
+ const sleep = options.sleep ?? sleepSync;
58
+ const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
59
+ const report = { removed: [], retained: [] };
60
+ for (const target of targets) {
61
+ // Only ever remove inside the workspace root we created.
62
+ const resolved = path.resolve(target);
63
+ if (resolved !== path.resolve(workspace.root) && !isInside(workspace.root, resolved)) {
64
+ report.retained.push({ path: resolved, reason: 'outside the QA workspace root' });
65
+ continue;
66
+ }
67
+ if (!fs.existsSync(resolved)) {
68
+ report.removed.push(resolved);
69
+ continue;
70
+ }
71
+ let lastError;
72
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
73
+ try {
74
+ fs.rmSync(resolved, { recursive: true, force: true });
75
+ lastError = undefined;
76
+ break;
77
+ }
78
+ catch (error) {
79
+ lastError = error;
80
+ if (attempt < attempts)
81
+ sleep(retryDelayMs);
82
+ }
83
+ }
84
+ if (lastError === undefined && !fs.existsSync(resolved))
85
+ report.removed.push(resolved);
86
+ else
87
+ report.retained.push({ path: resolved, reason: describeError(lastError) });
88
+ }
89
+ return report;
90
+ }
91
+ /**
92
+ * Sweep run directories left behind by earlier crashed runs. Bounded to the
93
+ * workspace's own `runs` directory and to entries older than `olderThanMs`, so a
94
+ * concurrent run is never removed out from under itself.
95
+ */
96
+ export function pruneAbandonedWorkspaces(root, { olderThanMs = 6 * 60 * 60_000, now = Date.now(), keep = [] } = {}) {
97
+ const runsRoot = path.resolve(root, 'runs');
98
+ const report = { removed: [], retained: [] };
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(runsRoot, { withFileTypes: true });
102
+ }
103
+ catch {
104
+ return report;
105
+ }
106
+ for (const entry of entries) {
107
+ if (!entry.isDirectory() || entry.isSymbolicLink())
108
+ continue;
109
+ if (keep.includes(entry.name))
110
+ continue;
111
+ const target = path.join(runsRoot, entry.name);
112
+ let ageMs;
113
+ try {
114
+ ageMs = now - fs.statSync(target).mtimeMs;
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ if (ageMs < olderThanMs) {
120
+ report.retained.push({ path: target, reason: `only ${Math.round(ageMs / 1000)}s old; may belong to a live run` });
121
+ continue;
122
+ }
123
+ try {
124
+ fs.rmSync(target, { recursive: true, force: true });
125
+ report.removed.push(target);
126
+ }
127
+ catch (error) {
128
+ report.retained.push({ path: target, reason: describeError(error) });
129
+ }
130
+ }
131
+ return report;
132
+ }
133
+ function isInside(root, candidate) {
134
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
135
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
136
+ }
137
+ function describeError(error) {
138
+ if (error === undefined)
139
+ return 'still present after removal reported success';
140
+ const code = error.code;
141
+ return code ? `${code}: ${error.message}` : String(error?.message ?? error);
142
+ }
143
+ function sleepSync(ms) {
144
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
145
+ }
146
+ function requireText(value, name) {
147
+ const normalized = String(value ?? '').trim();
148
+ if (!normalized)
149
+ throw new Error(`${name} is required`);
150
+ return normalized;
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Shared native QA runner, evidence, ownership, and release-backdoor guards for Right Suite apps.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",