@rightkit/qa 0.2.4 → 0.3.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/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # `@rightkit/qa`
2
2
 
3
- > **Which QA tool?** To drive a Right Suite desktop app under test, use the Rust **`rightkit-qa`** crate with **`rightkit-control`**; that is the one supported engine. For a test browser only, use the zero-dependency **`@rightkit/qa-chrome`**. For an agent driving an installed app, use **`right-ctl`**. This package's WebdriverIO native lane is deprecated and will be removed once its last consumer (Cockpit hub) has moved; do not adopt it in new apps.
3
+ > **Which QA tool?** To drive a Right Suite desktop app under test, use the Rust **`rightkit-qa`** crate with **`rightkit-control`**; that is the one supported engine. For a test browser only, use the zero-dependency **`@rightkit/qa-chrome`**. For an agent driving an installed app, use **`right-ctl`**. This package's WebdriverIO native lane & `right-qa` CLI were removed in 0.3.0.
4
4
 
5
- Shared native QA primitives for Right Suite desktop apps. The package owns run locking, bearer-authenticated loopback control, deterministic evidence bundles, exact owned-process cleanup, and the guard that keeps development WebDriver access out of production builds.
5
+ Shared QA primitives for Right Suite desktop apps. The package owns run locking, bearer-authenticated loopback control, deterministic evidence bundles, exact owned-process cleanup, and the guard that keeps development WebDriver access out of production builds.
6
6
 
7
7
  This package is intentionally private until the Windows and macOS phase gate passes. App repositories must never consume it through a Git, path, `file:`, `link:`, or `workspace:` dependency; adoption happens only after a registry version is published.
8
8
 
9
9
  ## Chrome for Testing (`@rightkit/qa/chrome`)
10
10
 
11
- Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on macOS each regular Chrome instance clones its app bundle under `<DARWIN_USER_TEMP_DIR>/../X/com.google.Chrome.code_sign_clone/`, and a killed instance leaks that clone. The native lane (`native.ts`, Tauri WebDriver) is unaffected.
11
+ Web/CDP harnesses launch Chrome for Testing, never regular Google Chrome: on macOS each regular Chrome instance clones its app bundle under `<DARWIN_USER_TEMP_DIR>/../X/com.google.Chrome.code_sign_clone/`, and a killed instance leaks that clone.
12
12
 
13
13
  - `resolveChromeForTesting({ executablePath? })` resolves the explicit option, then `RIGHTKIT_CHROME_FOR_TESTING`, then `/Applications` and `~/Applications` app installs, then the newest Puppeteer (`~/.cache/puppeteer`, `PUPPETEER_CACHE_DIR`) and Playwright (`~/Library/Caches/ms-playwright`, `PLAYWRIGHT_BROWSERS_PATH`) builds. It throws `ChromeForTestingNotFoundError` with install instructions; it never falls back. `findChromeForTesting()` returns `{ path }` or `{ reason }` for test skips.
14
14
  - `launchChromeForTesting(options)` starts it in its own process group with a temp profile and returns `{ wsEndpoint, port, pid, profileDir, close() }`. Launch one browser per suite and open targets per test.
package/dist/index.d.ts CHANGED
@@ -3,9 +3,9 @@ export * from './evidence.js';
3
3
  export * from './frames.js';
4
4
  export * from './guard.js';
5
5
  export * from './loopback.js';
6
- export * from './native.js';
7
6
  export * from './ownership.js';
8
7
  export * from './paint.js';
9
8
  export * from './panes.js';
10
9
  export * from './process.js';
11
10
  export * from './workspace.js';
11
+ export * from './storage.js';
package/dist/index.js CHANGED
@@ -3,9 +3,9 @@ export * from './evidence.js';
3
3
  export * from './frames.js';
4
4
  export * from './guard.js';
5
5
  export * from './loopback.js';
6
- export * from './native.js';
7
6
  export * from './ownership.js';
8
7
  export * from './paint.js';
9
8
  export * from './panes.js';
10
9
  export * from './process.js';
11
10
  export * from './workspace.js';
11
+ export * from './storage.js';
@@ -0,0 +1,50 @@
1
+ export declare const RUN_MARKER = ".rightkit-run.json";
2
+ export declare const DEFAULT_RETENTION_BYTES = 20000000000;
3
+ export declare const DEFAULT_YOUNG_MS: number;
4
+ export declare const DEFAULT_KEEP_COUNT = 5;
5
+ export interface RunMarker {
6
+ schemaVersion: 1;
7
+ app: string;
8
+ runId: string;
9
+ createdAt: number;
10
+ ownerPid: number;
11
+ leaseUntil: number;
12
+ completedAt?: number;
13
+ }
14
+ export interface ManagedRun {
15
+ root: string;
16
+ app: string;
17
+ runId: string;
18
+ marker: RunMarker;
19
+ }
20
+ export interface RetentionPolicy {
21
+ maxBytes?: number;
22
+ keepCount?: number;
23
+ youngMs?: number;
24
+ now?: number;
25
+ keepRunIds?: string[];
26
+ }
27
+ export interface RetentionReport {
28
+ removed: string[];
29
+ retained: Array<{
30
+ path: string;
31
+ reason: string;
32
+ }>;
33
+ totalBytes: number;
34
+ capBytes: number;
35
+ overCapBytes: number;
36
+ blockedBytes: number;
37
+ unknownBytes: number;
38
+ }
39
+ export declare function resolveManagedRoot(explicit?: string): string;
40
+ export declare function safeKey(value: string, name: string): string;
41
+ export declare function runRoot(managedRoot: string, app: string, runId: string): string;
42
+ export declare function createManagedRun(options: {
43
+ managedRoot?: string;
44
+ app: string;
45
+ runId?: string;
46
+ leaseMs?: number;
47
+ retention?: RetentionPolicy;
48
+ }): ManagedRun;
49
+ export declare function finishManagedRun(run: ManagedRun | string): RunMarker | undefined;
50
+ export declare function pruneManagedRuns(managedRoot: string, app: string, policy?: RetentionPolicy): RetentionReport;
@@ -0,0 +1,315 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const RUN_MARKER = '.rightkit-run.json';
4
+ export const DEFAULT_RETENTION_BYTES = 20_000_000_000;
5
+ export const DEFAULT_YOUNG_MS = 24 * 60 * 60_000;
6
+ export const DEFAULT_KEEP_COUNT = 5;
7
+ const LOCK_WAIT_MS = 5_000;
8
+ export function resolveManagedRoot(explicit) {
9
+ if (explicit?.trim())
10
+ return path.resolve(explicit);
11
+ if (process.env.RIGHTKIT_MANAGED_ROOT?.trim())
12
+ return path.resolve(process.env.RIGHTKIT_MANAGED_ROOT);
13
+ if (process.platform === 'win32')
14
+ return path.resolve('D:\\.rightkit-managed');
15
+ return '/Volumes/D/.rightkit-managed';
16
+ }
17
+ export function safeKey(value, name) {
18
+ const v = String(value ?? '').trim();
19
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v))
20
+ throw new Error(`unsafe QA ${name}: ${value}`);
21
+ return v;
22
+ }
23
+ export function runRoot(managedRoot, app, runId) {
24
+ const root = path.resolve(managedRoot);
25
+ const appKey = safeKey(app, 'app');
26
+ const id = safeKey(runId, 'run id');
27
+ const result = path.join(root, 'runs', appKey, id);
28
+ if (!isInside(path.join(root, 'runs', appKey), result))
29
+ throw new Error('QA run path escapes managed root');
30
+ return result;
31
+ }
32
+ export function createManagedRun(options) {
33
+ const managedRoot = resolveManagedRoot(options.managedRoot);
34
+ const app = safeKey(options.app, 'app');
35
+ const runId = safeKey(options.runId ?? cryptoRandomId(), 'run id');
36
+ const appRoot = path.join(managedRoot, 'runs', app);
37
+ withRetentionLock(appRoot, () => {
38
+ ensureSafeDirectory(managedRoot);
39
+ ensureSafeDirectory(path.join(managedRoot, 'runs'));
40
+ ensureSafeDirectory(appRoot);
41
+ const retention = { now: Date.now(), ...(options.retention ?? {}) };
42
+ const report = { removed: [], retained: [], totalBytes: 0, capBytes: DEFAULT_RETENTION_BYTES, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
43
+ pruneUnlocked(appRoot, app, retention, report);
44
+ if (report.overCapBytes > 0)
45
+ throw new Error(`QA retention cap is blocked by ${report.overCapBytes} bytes of protected or unknown data`);
46
+ const root = runRoot(managedRoot, app, runId);
47
+ if (fs.existsSync(root))
48
+ throw new Error(`QA run already exists: ${root}`);
49
+ fs.mkdirSync(root, { recursive: true });
50
+ const marker = {
51
+ schemaVersion: 1,
52
+ app,
53
+ runId,
54
+ createdAt: Date.now(),
55
+ ownerPid: process.pid,
56
+ leaseUntil: Date.now() + Math.max(1, options.leaseMs ?? DEFAULT_YOUNG_MS),
57
+ };
58
+ writeMarker(root, marker);
59
+ });
60
+ const root = runRoot(managedRoot, app, runId);
61
+ const marker = readMarker(path.join(root, RUN_MARKER));
62
+ if (!marker)
63
+ throw new Error(`QA run marker was not created: ${root}`);
64
+ return { root, app, runId, marker };
65
+ }
66
+ export function finishManagedRun(run) {
67
+ const root = typeof run === 'string' ? run : run.root;
68
+ const markerPath = path.join(path.resolve(root), RUN_MARKER);
69
+ const marker = readMarker(markerPath);
70
+ if (!marker)
71
+ return undefined;
72
+ const finished = { ...marker, ownerPid: 0, leaseUntil: 0, completedAt: Date.now() };
73
+ writeMarker(root, finished);
74
+ return finished;
75
+ }
76
+ export function pruneManagedRuns(managedRoot, app, policy = {}) {
77
+ const root = resolveManagedRoot(managedRoot);
78
+ const appKey = safeKey(app, 'app');
79
+ const appRoot = path.join(root, 'runs', appKey);
80
+ const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
81
+ const report = { removed: [], retained: [], totalBytes: 0, capBytes: cap, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
82
+ if (!safeDirectory(appRoot))
83
+ return report;
84
+ return withRetentionLock(appRoot, () => pruneUnlocked(appRoot, appKey, policy, report));
85
+ }
86
+ function pruneUnlocked(appRoot, app, policy, report) {
87
+ const now = policy.now ?? Date.now();
88
+ const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
89
+ report.capBytes = cap;
90
+ const keepCount = Math.max(0, Math.floor(policy.keepCount ?? DEFAULT_KEEP_COUNT));
91
+ const youngMs = Math.max(0, policy.youngMs ?? DEFAULT_YOUNG_MS);
92
+ const candidates = [];
93
+ let total = 0;
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(appRoot, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ return report;
100
+ }
101
+ for (const entry of entries) {
102
+ if (entry.name.startsWith('.'))
103
+ continue;
104
+ if (entry.isSymbolicLink()) {
105
+ report.retained.push({ path: path.join(appRoot, entry.name), reason: 'symlink skipped' });
106
+ continue;
107
+ }
108
+ if (!entry.isDirectory())
109
+ continue;
110
+ const runPath = path.join(appRoot, entry.name);
111
+ if (!isInside(appRoot, runPath))
112
+ continue;
113
+ const marker = readMarker(path.join(runPath, RUN_MARKER));
114
+ if (!marker || marker.app !== app || marker.runId !== entry.name) {
115
+ const bytes = directoryBytes(runPath);
116
+ if (bytes !== undefined)
117
+ report.unknownBytes += bytes;
118
+ report.retained.push({ path: runPath, reason: 'unknown or malformed run metadata' });
119
+ continue;
120
+ }
121
+ const bytes = directoryBytes(runPath);
122
+ if (bytes === undefined) {
123
+ report.retained.push({ path: runPath, reason: 'symlink or unreadable content skipped' });
124
+ continue;
125
+ }
126
+ const active = marker.ownerPid > 0 && isProcessLive(marker.ownerPid) || marker.leaseUntil > now;
127
+ total += bytes;
128
+ candidates.push({ root: runPath, marker, bytes, active });
129
+ }
130
+ candidates.sort((a, b) => b.marker.createdAt - a.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
131
+ const countProtected = new Set(candidates.slice(0, keepCount).map((x) => x.marker.runId));
132
+ const protectedIds = new Set();
133
+ const callerProtected = new Set(policy.keepRunIds ?? []);
134
+ for (const item of candidates) {
135
+ if (now - item.marker.createdAt < youngMs)
136
+ protectedIds.add(item.marker.runId);
137
+ if (item.active)
138
+ protectedIds.add(item.marker.runId);
139
+ if (callerProtected.has(item.marker.runId))
140
+ protectedIds.add(item.marker.runId);
141
+ }
142
+ const old = candidates
143
+ .filter((x) => !protectedIds.has(x.marker.runId) && (total > cap || !countProtected.has(x.marker.runId)))
144
+ .sort((a, b) => a.marker.createdAt - b.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
145
+ const removed = new Set();
146
+ for (const item of old) {
147
+ if (total <= cap && countProtected.has(item.marker.runId))
148
+ continue;
149
+ if (!safeDirectory(item.root))
150
+ continue;
151
+ try {
152
+ fs.rmSync(item.root, { recursive: true, force: false });
153
+ report.removed.push(item.root);
154
+ removed.add(item.marker.runId);
155
+ total -= item.bytes;
156
+ }
157
+ catch (error) {
158
+ report.retained.push({ path: item.root, reason: describeError(error) });
159
+ }
160
+ }
161
+ for (const item of candidates) {
162
+ if (!removed.has(item.marker.runId) && (protectedIds.has(item.marker.runId) || (total <= cap && countProtected.has(item.marker.runId))))
163
+ report.retained.push({ path: item.root, reason: callerProtected.has(item.marker.runId) ? 'caller protected run' : item.active ? 'active run; may belong to a live run' : (now - item.marker.createdAt < youngMs ? 'younger than retention window' : 'newest protected run') });
164
+ }
165
+ report.blockedBytes = candidates.filter((item) => !removed.has(item.marker.runId) && (protectedIds.has(item.marker.runId) || (total <= cap && countProtected.has(item.marker.runId)))).reduce((sum, item) => sum + item.bytes, 0);
166
+ report.totalBytes = total + report.unknownBytes;
167
+ report.overCapBytes = Math.max(0, report.totalBytes - report.capBytes);
168
+ return report;
169
+ }
170
+ function withRetentionLock(appRoot, body) {
171
+ ensureSafeDirectory(path.dirname(appRoot));
172
+ ensureSafeDirectory(appRoot);
173
+ const lock = path.join(appRoot, '.retention-lock');
174
+ const owner = path.join(lock, 'owner.json');
175
+ const deadline = Date.now() + LOCK_WAIT_MS;
176
+ while (true) {
177
+ try {
178
+ fs.mkdirSync(lock);
179
+ fs.writeFileSync(owner, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), 'utf8');
180
+ break;
181
+ }
182
+ catch (error) {
183
+ if (error.code !== 'EEXIST')
184
+ throw error;
185
+ let pid;
186
+ try {
187
+ pid = JSON.parse(fs.readFileSync(owner, 'utf8')).pid;
188
+ }
189
+ catch { /* uncertain owner: wait */ }
190
+ if (typeof pid === 'number' && pid > 0 && !isProcessLive(pid)) {
191
+ try {
192
+ fs.rmSync(lock, { recursive: true, force: true });
193
+ }
194
+ catch { /* retry */ }
195
+ }
196
+ else if (Date.now() >= deadline) {
197
+ throw new Error(`QA retention lock is held: ${lock}`);
198
+ }
199
+ if (Date.now() < deadline)
200
+ sleepSync(20);
201
+ }
202
+ }
203
+ try {
204
+ return body();
205
+ }
206
+ finally {
207
+ try {
208
+ fs.rmSync(lock, { recursive: true, force: true });
209
+ }
210
+ catch { /* best effort */ }
211
+ }
212
+ }
213
+ function writeMarker(root, marker) {
214
+ const target = path.join(root, RUN_MARKER);
215
+ const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
216
+ fs.writeFileSync(temp, `${JSON.stringify(marker)}\n`, { encoding: 'utf8', flag: 'wx' });
217
+ fs.renameSync(temp, target);
218
+ }
219
+ function readMarker(file) {
220
+ try {
221
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
222
+ if (!raw || typeof raw !== 'object')
223
+ return undefined;
224
+ const x = raw;
225
+ if (x.schemaVersion !== 1 || typeof x.app !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(x.app) || typeof x.runId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(x.runId) || typeof x.createdAt !== 'number' || !Number.isFinite(x.createdAt) || x.createdAt <= 0 || typeof x.ownerPid !== 'number' || !Number.isInteger(x.ownerPid) || x.ownerPid < 0 || typeof x.leaseUntil !== 'number' || !Number.isFinite(x.leaseUntil) || x.leaseUntil < 0)
226
+ return undefined;
227
+ if (x.completedAt !== undefined && (typeof x.completedAt !== 'number' || !Number.isFinite(x.completedAt) || x.completedAt < 0))
228
+ return undefined;
229
+ return x;
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ }
235
+ function directoryBytes(root) {
236
+ let total = 0;
237
+ const walk = (dir) => {
238
+ let entries;
239
+ try {
240
+ entries = fs.readdirSync(dir, { withFileTypes: true });
241
+ }
242
+ catch {
243
+ return false;
244
+ }
245
+ for (const entry of entries) {
246
+ const p = path.join(dir, entry.name);
247
+ if (entry.isSymbolicLink())
248
+ return false;
249
+ if (entry.isDirectory()) {
250
+ if (!walk(p))
251
+ return false;
252
+ }
253
+ else if (entry.isFile()) {
254
+ try {
255
+ total += fs.statSync(p).size;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
260
+ }
261
+ else
262
+ return false;
263
+ }
264
+ return true;
265
+ };
266
+ return walk(root) ? total : undefined;
267
+ }
268
+ function safeDirectory(dir) {
269
+ try {
270
+ return fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink();
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ function ensureSafeDirectory(dir) {
277
+ const absolute = path.resolve(dir);
278
+ const parts = absolute.split(path.sep);
279
+ let current = parts[0] || path.sep;
280
+ for (const part of parts.slice(1)) {
281
+ if (!part)
282
+ continue;
283
+ current = path.join(current, part);
284
+ try {
285
+ if (fs.lstatSync(current).isSymbolicLink())
286
+ throw new Error(`refusing symlink in managed path: ${current}`);
287
+ }
288
+ catch (error) {
289
+ if (error.code !== 'ENOENT')
290
+ throw error;
291
+ }
292
+ }
293
+ fs.mkdirSync(absolute, { recursive: true });
294
+ }
295
+ function isInside(root, candidate) {
296
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
297
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
298
+ }
299
+ function isProcessLive(pid) {
300
+ if (!Number.isInteger(pid) || pid <= 0)
301
+ return false;
302
+ try {
303
+ process.kill(pid, 0);
304
+ return true;
305
+ }
306
+ catch (error) {
307
+ return error.code === 'EPERM';
308
+ }
309
+ }
310
+ function describeError(error) {
311
+ const x = error;
312
+ return x?.code ? `${x.code}: ${x.message}` : String(error?.message ?? error);
313
+ }
314
+ function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
315
+ function cryptoRandomId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; }
@@ -1,12 +1,18 @@
1
+ import { type ManagedRun } from './storage.js';
1
2
  export interface QaWorkspace {
2
3
  runId: string;
4
+ appKey: string;
5
+ managedRoot: string;
3
6
  root: string;
4
7
  /** Isolated user-data directory; pass to the app so it takes its own lock. */
5
8
  dataDir: string;
6
9
  /** Somewhere for screenshots, logs, and other recorded evidence. */
7
10
  evidenceDir: string;
11
+ /** Managed capture output directory. */
12
+ captureDir: string;
8
13
  /** Environment overrides that point the app at this workspace. */
9
14
  env: Record<string, string>;
15
+ marker: ManagedRun['marker'];
10
16
  }
11
17
  export interface DisposeReport {
12
18
  removed: string[];
@@ -16,9 +22,12 @@ export interface DisposeReport {
16
22
  }>;
17
23
  }
18
24
  export interface CreateQaWorkspaceOptions {
19
- root: string;
25
+ /** Managed root override; tests may provide a temporary directory. */
26
+ root?: string;
20
27
  runId?: string;
21
28
  appKey: string;
29
+ leaseMs?: number;
30
+ retention?: import('./storage.js').RetentionPolicy;
22
31
  }
23
32
  export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
24
33
  export interface DisposeOptions {
package/dist/workspace.js CHANGED
@@ -17,26 +17,38 @@
17
17
  import crypto from 'node:crypto';
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { createManagedRun, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
20
21
  export function createQaWorkspace(options) {
21
22
  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);
23
+ const managed = createManagedRun({ managedRoot: options.root ? requireText(options.root, 'root') : resolveManagedRoot(), app: appKey, runId: options.runId ?? crypto.randomUUID(), leaseMs: options.leaseMs, retention: options.retention });
24
+ const runId = managed.runId;
25
+ const root = managed.root;
26
26
  const dataDir = path.join(root, 'data');
27
27
  const evidenceDir = path.join(root, 'evidence');
28
+ const captureDir = path.join(root, 'captures');
29
+ const tmpDir = path.join(root, 'tmp');
28
30
  fs.mkdirSync(dataDir, { recursive: true });
29
31
  fs.mkdirSync(evidenceDir, { recursive: true });
32
+ fs.mkdirSync(captureDir, { recursive: true });
33
+ fs.mkdirSync(tmpDir, { recursive: true });
30
34
  return {
31
35
  runId,
36
+ appKey,
37
+ managedRoot: path.resolve(options.root ? requireText(options.root, 'root') : resolveManagedRoot()),
32
38
  root,
33
39
  dataDir,
34
40
  evidenceDir,
41
+ captureDir,
35
42
  env: {
36
43
  RIGHTKIT_QA_NATIVE: '1',
37
44
  RIGHTKIT_QA_RUN_ID: runId,
45
+ RIGHTKIT_RUN_ROOT: root,
38
46
  RIGHTKIT_QA_DATA_DIR: dataDir,
39
47
  RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
48
+ RIGHTKIT_QA_CAPTURE_DIR: captureDir,
49
+ TMPDIR: tmpDir,
50
+ TMP: tmpDir,
51
+ TEMP: tmpDir,
40
52
  // Tauri/webview honour these for profile isolation; setting all three keeps
41
53
  // the run off the user's real profile regardless of which the app reads.
42
54
  XDG_DATA_HOME: dataDir,
@@ -44,6 +56,7 @@ export function createQaWorkspace(options) {
44
56
  WEBVIEW2_USER_DATA_FOLDER: path.join(dataDir, 'webview2'),
45
57
  [`${appKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DATA_DIR`]: dataDir,
46
58
  },
59
+ marker: managed.marker,
47
60
  };
48
61
  }
49
62
  /**
@@ -57,6 +70,7 @@ export function disposeQaWorkspace(workspace, options = {}) {
57
70
  const sleep = options.sleep ?? sleepSync;
58
71
  const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
59
72
  const report = { removed: [], retained: [] };
73
+ finishManagedRun(workspace.root);
60
74
  for (const target of targets) {
61
75
  // Only ever remove inside the workspace root we created.
62
76
  const resolved = path.resolve(target);
@@ -93,39 +107,25 @@ export function disposeQaWorkspace(workspace, options = {}) {
93
107
  * workspace's own `runs` directory and to entries older than `olderThanMs`, so a
94
108
  * concurrent run is never removed out from under itself.
95
109
  */
96
- export function pruneAbandonedWorkspaces(root, { olderThanMs = 6 * 60 * 60_000, now = Date.now(), keep = [] } = {}) {
97
- const runsRoot = path.resolve(root, 'runs');
110
+ export function pruneAbandonedWorkspaces(root, { olderThanMs, now = Date.now(), keep = [] } = {}) {
111
+ const managedRoot = path.resolve(root);
98
112
  const report = { removed: [], retained: [] };
99
- let entries;
113
+ const runsRoot = path.join(managedRoot, 'runs');
114
+ let apps;
100
115
  try {
101
- entries = fs.readdirSync(runsRoot, { withFileTypes: true });
116
+ apps = fs.readdirSync(runsRoot, { withFileTypes: true });
102
117
  }
103
118
  catch {
104
119
  return report;
105
120
  }
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) });
121
+ for (const app of apps) {
122
+ if (app.isDirectory() && !app.isSymbolicLink()) {
123
+ const policy = olderThanMs === undefined
124
+ ? { now, keepRunIds: keep }
125
+ : { now, youngMs: olderThanMs, keepCount: 0, maxBytes: 0, keepRunIds: keep };
126
+ const sub = pruneManagedRuns(managedRoot, app.name, policy);
127
+ report.removed.push(...sub.removed);
128
+ report.retained.push(...sub.retained);
129
129
  }
130
130
  }
131
131
  return report;
package/package.json CHANGED
@@ -1,14 +1,11 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.2.4",
4
- "description": "Shared native QA runner, evidence, ownership, and release-backdoor guards for Right Suite apps.",
3
+ "version": "0.3.1",
4
+ "description": "Shared QA evidence, ownership, browser helpers, and release-backdoor guards for Right Suite apps.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
- "bin": {
10
- "right-qa": "dist/cli.js"
11
- },
12
9
  "exports": {
13
10
  ".": {
14
11
  "types": "./dist/index.d.ts",
@@ -27,14 +24,7 @@
27
24
  ],
28
25
  "sideEffects": false,
29
26
  "dependencies": {
30
- "@rightkit/qa-chrome": "0.1.2",
31
- "@wdio/cli": "9.27.1",
32
- "@wdio/local-runner": "9.27.1",
33
- "@wdio/mocha-framework": "9.27.1",
34
- "@wdio/spec-reporter": "9.27.1",
35
- "@wdio/tauri-service": "1.2.0",
36
- "undici": "6.27.0",
37
- "webdriverio": "9.27.1"
27
+ "@rightkit/qa-chrome": "0.1.3"
38
28
  },
39
29
  "devDependencies": {
40
30
  "@types/node": "^24.0.0",
@@ -53,6 +43,6 @@
53
43
  "scripts": {
54
44
  "build": "tsc -p tsconfig.json",
55
45
  "typecheck": "tsc -p tsconfig.json --noEmit",
56
- "test": "vitest run"
46
+ "test": "vitest run && node --test public-storage.test.mjs"
57
47
  }
58
48
  }
package/dist/cli.d.ts DELETED
@@ -1,20 +0,0 @@
1
- #!/usr/bin/env node
2
- import type { NativeQaRuntimeOptions, RightQaConfig } from './native.js';
3
- import type { OwnedProcessResult } from './process.js';
4
- type RunProcess = (command: string, args: string[], options: {
5
- cwd: string;
6
- timeoutMs: number;
7
- env: NodeJS.ProcessEnv;
8
- }) => Promise<OwnedProcessResult>;
9
- export interface NativeRunOptions extends NativeQaRuntimeOptions {
10
- timeoutMs?: number;
11
- wdioBin?: string;
12
- runProcess?: RunProcess;
13
- }
14
- export interface NativeRunResult {
15
- exitCode: number;
16
- evidencePath: string;
17
- }
18
- export declare function runNativeQa(rawConfig: RightQaConfig, options?: NativeRunOptions): Promise<NativeRunResult>;
19
- export declare function loadRightQaConfig(configPath: string): Promise<RightQaConfig>;
20
- export {};
package/dist/cli.js DELETED
@@ -1,162 +0,0 @@
1
- #!/usr/bin/env node
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { createRequire } from 'node:module';
6
- import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { addEvidenceArtifact, addEvidenceCheck, createEvidenceBundle, finishEvidenceBundle, writeEvidenceBundle, } from './evidence.js';
8
- import { createQaWorkspace, pruneAbandonedWorkspaces } from './workspace.js';
9
- import { createWdioConfig, defineRightQaConfig } from './native.js';
10
- import { acquireRunOwnership } from './ownership.js';
11
- import { runOwnedProcess } from './process.js';
12
- export async function runNativeQa(rawConfig, options = {}) {
13
- const app = defineRightQaConfig(rawConfig);
14
- const platform = options.platform ?? requireDesktopPlatform();
15
- const wdioConfig = createWdioConfig(app, { platform, embeddedPort: options.embeddedPort });
16
- const binary = String(wdioConfig.capabilities[0]?.['tauri:options']?.application ?? '');
17
- if (!fs.existsSync(binary))
18
- throw new Error(`native QA binary not found: ${binary}`);
19
- const cacheRoot = path.resolve(app.appRoot, app.evidenceDir ?? '.cache/rightkit-qa');
20
- const runId = crypto.randomUUID();
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;
26
- const generatedConfig = path.join(runRoot, 'wdio.generated.mjs');
27
- const evidencePath = path.join(runRoot, 'evidence.json');
28
- fs.writeFileSync(generatedConfig, [
29
- `import { Agent, setGlobalDispatcher } from ${JSON.stringify(resolveUndiciEntry())};`,
30
- 'setGlobalDispatcher(new Agent());',
31
- `const config = ${JSON.stringify(wdioConfig, null, 2)};`,
32
- 'config.beforeSession = () => { setGlobalDispatcher(new Agent()); };',
33
- 'export { config };',
34
- '',
35
- ].join('\n'), 'utf8');
36
- const ownership = await acquireRunOwnership(path.join(cacheRoot, 'native.lock'), `${app.app}:native`);
37
- const evidence = createEvidenceBundle({ app: app.app, platform, runId });
38
- const runProcess = options.runProcess ?? runOwnedProcess;
39
- try {
40
- const result = await runProcess(process.execPath, [options.wdioBin ?? resolveWdioBin(), 'run', generatedConfig], {
41
- cwd: app.appRoot,
42
- timeoutMs: options.timeoutMs ?? 5 * 60_000,
43
- env: {
44
- ...process.env,
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,
48
- RIGHTKIT_QA_HIDDEN: '1',
49
- TAURI_WEBDRIVER_PORT: String(options.embeddedPort ?? 4445),
50
- },
51
- });
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'));
56
- addEvidenceCheck(evidence, {
57
- name: 'wdio.run',
58
- status: passed ? 'passed' : 'failed',
59
- artifacts: logArtifacts,
60
- details: {
61
- code: result.code,
62
- signal: result.signal,
63
- stdoutTail: tail(result.stdout),
64
- stderrTail: tail(result.stderr),
65
- },
66
- });
67
- finishEvidenceBundle(evidence, { status: passed ? 'passed' : 'failed' });
68
- writeEvidenceBundle(evidencePath, evidence);
69
- return { exitCode: passed ? 0 : 1, evidencePath };
70
- }
71
- catch (error) {
72
- addEvidenceCheck(evidence, {
73
- name: 'wdio.run',
74
- status: 'failed',
75
- details: { error: error instanceof Error ? error.message : String(error) },
76
- });
77
- finishEvidenceBundle(evidence, { status: 'failed' });
78
- writeEvidenceBundle(evidencePath, evidence);
79
- throw error;
80
- }
81
- finally {
82
- ownership.release();
83
- }
84
- }
85
- export async function loadRightQaConfig(configPath) {
86
- const resolved = path.resolve(configPath);
87
- const loaded = await import(`${pathToFileURL(resolved).href}?t=${Date.now()}`);
88
- if (!loaded.default)
89
- throw new Error(`${resolved} must export a default RightKit QA config`);
90
- return defineRightQaConfig(loaded.default);
91
- }
92
- async function main(argv = process.argv.slice(2)) {
93
- const command = argv[0];
94
- const configArg = valueAfter(argv, '--config') ?? 'right-qa.config.mjs';
95
- if (command === 'doctor') {
96
- const config = await loadRightQaConfig(configArg);
97
- process.stdout.write(`right-qa config valid: ${config.app}\n`);
98
- return 0;
99
- }
100
- if (command === 'native') {
101
- const config = await loadRightQaConfig(configArg);
102
- const result = await runNativeQa(config);
103
- process.stdout.write(`right-qa evidence: ${result.evidencePath}\n`);
104
- return result.exitCode;
105
- }
106
- throw new Error('usage: right-qa <doctor|native> [--config right-qa.config.mjs]');
107
- }
108
- function resolveWdioBin() {
109
- const require = createRequire(import.meta.url);
110
- const entry = require.resolve('@wdio/cli');
111
- return path.resolve(path.dirname(entry), '../bin/wdio.js');
112
- }
113
- function resolveUndiciEntry() {
114
- const require = createRequire(import.meta.url);
115
- return pathToFileURL(require.resolve('undici')).href;
116
- }
117
- function requireDesktopPlatform() {
118
- if (process.platform === 'win32' || process.platform === 'darwin')
119
- return process.platform;
120
- throw new Error(`Right Suite native QA supports Windows and macOS, received ${process.platform}`);
121
- }
122
- function valueAfter(argv, flag) {
123
- const index = argv.indexOf(flag);
124
- return index >= 0 ? argv[index + 1] : undefined;
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
- }
154
- function tail(value, maxLength = 4_000) {
155
- return value.length <= maxLength ? value : value.slice(-maxLength);
156
- }
157
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
158
- main().then((code) => { process.exitCode = code; }, (error) => {
159
- process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
160
- process.exitCode = 1;
161
- });
162
- }
package/dist/native.d.ts DELETED
@@ -1,37 +0,0 @@
1
- export interface RightQaConfig {
2
- app: string;
3
- appRoot: string;
4
- windowLabel: string;
5
- specs: string[];
6
- binaries: {
7
- win32: string;
8
- darwin: string;
9
- };
10
- evidenceDir?: string;
11
- }
12
- export interface NativeQaRuntimeOptions {
13
- platform?: 'win32' | 'darwin';
14
- embeddedPort?: number;
15
- }
16
- export interface WdioNativeConfig {
17
- runner: 'local';
18
- specs: string[];
19
- maxInstances: 1;
20
- maxInstancesPerCapability: 1;
21
- services: Array<[string, Record<string, unknown>]>;
22
- capabilities: Array<Record<string, unknown>>;
23
- framework: 'mocha';
24
- reporters: string[];
25
- mochaOpts: {
26
- ui: 'bdd';
27
- timeout: number;
28
- };
29
- logLevel: 'error';
30
- bail: number;
31
- waitforTimeout: number;
32
- connectionRetryTimeout: number;
33
- connectionRetryCount: number;
34
- outputDir: string;
35
- }
36
- export declare function defineRightQaConfig(input: RightQaConfig): RightQaConfig;
37
- export declare function createWdioConfig(rawConfig: RightQaConfig, options?: NativeQaRuntimeOptions): WdioNativeConfig;
package/dist/native.js DELETED
@@ -1,78 +0,0 @@
1
- import path from 'node:path';
2
- export function defineRightQaConfig(input) {
3
- const app = requireText(input.app, 'app');
4
- if (!/^[a-z][a-z0-9-]*$/.test(app))
5
- throw new Error('app must be a lowercase app key');
6
- const appRoot = path.resolve(requireText(input.appRoot, 'appRoot'));
7
- const specs = input.specs.map((spec) => requireText(spec, 'spec'));
8
- if (specs.length === 0)
9
- throw new Error('at least one native QA spec is required');
10
- return Object.freeze({
11
- ...input,
12
- app,
13
- appRoot,
14
- windowLabel: requireText(input.windowLabel, 'windowLabel'),
15
- specs: Object.freeze([...specs]),
16
- binaries: Object.freeze({
17
- win32: requireText(input.binaries.win32, 'win32 binary'),
18
- darwin: requireText(input.binaries.darwin, 'darwin binary'),
19
- }),
20
- });
21
- }
22
- export function createWdioConfig(rawConfig, options = {}) {
23
- const app = defineRightQaConfig(rawConfig);
24
- const platform = options.platform ?? currentDesktopPlatform();
25
- const embeddedPort = options.embeddedPort ?? 4445;
26
- if (!Number.isInteger(embeddedPort) || embeddedPort < 1024 || embeddedPort > 65_535) {
27
- throw new Error(`invalid embedded WebDriver port: ${embeddedPort}`);
28
- }
29
- const appBinaryPath = path.resolve(app.appRoot, app.binaries[platform]);
30
- const evidenceRoot = path.resolve(app.appRoot, app.evidenceDir ?? '.cache/rightkit-qa');
31
- return {
32
- runner: 'local',
33
- specs: app.specs.map((spec) => path.resolve(app.appRoot, spec)),
34
- maxInstances: 1,
35
- maxInstancesPerCapability: 1,
36
- services: [[
37
- '@wdio/tauri-service',
38
- {
39
- appBinaryPath,
40
- driverProvider: 'embedded',
41
- embeddedPort,
42
- windowLabel: app.windowLabel,
43
- autoInstallTauriDriver: false,
44
- autoDownloadEdgeDriver: true,
45
- captureBackendLogs: false,
46
- captureFrontendLogs: false,
47
- startTimeout: 60_000,
48
- statusPollTimeout: 5_000,
49
- commandTimeout: 30_000,
50
- logDir: path.join(evidenceRoot, 'service'),
51
- },
52
- ]],
53
- capabilities: [{
54
- browserName: 'tauri',
55
- 'tauri:options': { application: appBinaryPath },
56
- }],
57
- framework: 'mocha',
58
- reporters: ['spec'],
59
- mochaOpts: { ui: 'bdd', timeout: 60_000 },
60
- logLevel: 'error',
61
- bail: 1,
62
- waitforTimeout: 10_000,
63
- connectionRetryTimeout: 90_000,
64
- connectionRetryCount: 1,
65
- outputDir: path.join(evidenceRoot, 'wdio'),
66
- };
67
- }
68
- function currentDesktopPlatform() {
69
- if (process.platform === 'win32' || process.platform === 'darwin')
70
- return process.platform;
71
- throw new Error(`Right Suite native QA currently supports Windows and macOS, received ${process.platform}`);
72
- }
73
- function requireText(value, name) {
74
- const normalized = value.trim();
75
- if (!normalized)
76
- throw new Error(`${name} is required`);
77
- return normalized;
78
- }