@rightkit/qa 0.3.1 → 0.3.2

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
@@ -6,6 +6,14 @@ Shared QA primitives for Right Suite desktop apps. The package owns run locking,
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
+ ## Run storage
10
+
11
+ Run roots, harness evidence, captures & summaries remain under `<managed>/runs/<app>/<run-id>`. App homes, profiles, service/port files, live logs & app-provided output paths use an owned internal system-temp companion recorded as `appRoot` in `.rightkit-run.json`. On macOS, companions always use `/private/tmp/rightkit-qa/<app-prefix>-<random>` with short names so launchd can open app files & control socket paths fit `sockaddr_un`.
12
+
13
+ Finish copies app logs to managed `evidence/`, app-written evidence to `evidence/app/` & app captures to `captures/` before releasing ownership. Disposal removes temp companions; failed copies or cleanup preserve ownership metadata for recovery. Retention & orphan pruning validate companion bindings & protect live runs.
14
+
15
+ Workspace env sets `RIGHTKIT_RUN_ROOT`, `HOME` & `USERPROFILE` to `home`; `RIGHTKIT_SUITE_ROOT` to `home/suite`; data/profile keys to `home/data`; `XDG_CACHE_HOME` to `home/cache`; `TMPDIR`, `TMP` & `TEMP` to `home/tmp`. `RIGHTKIT_QA_EVIDENCE_DIR` & `RIGHTKIT_QA_CAPTURE_DIR` use `home/evidence` & `home/captures`. The JS package supplies workspace env; native UI bundle staging & unique per-launch suite/service identity belong to Rust `control::launch`.
16
+
9
17
  ## Chrome for Testing (`@rightkit/qa/chrome`)
10
18
 
11
19
  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.
package/dist/storage.d.ts CHANGED
@@ -10,6 +10,8 @@ export interface RunMarker {
10
10
  ownerPid: number;
11
11
  leaseUntil: number;
12
12
  completedAt?: number;
13
+ /** Owned internal-temp companion; validated against its binding before cleanup. */
14
+ appRoot?: string;
13
15
  }
14
16
  export interface ManagedRun {
15
17
  root: string;
@@ -48,3 +50,7 @@ export declare function createManagedRun(options: {
48
50
  }): ManagedRun;
49
51
  export declare function finishManagedRun(run: ManagedRun | string): RunMarker | undefined;
50
52
  export declare function pruneManagedRuns(managedRoot: string, app: string, policy?: RetentionPolicy): RetentionReport;
53
+ /** Short internal paths keep launchd files accessible & service sockets within sockaddr_un. */
54
+ export declare function appTempDir(): string;
55
+ export declare function createManagedAppRoot(run: ManagedRun): string;
56
+ export declare function removeManagedAppRoot(root: string, marker: RunMarker): void;
package/dist/storage.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import os from 'node:os';
2
3
  import path from 'node:path';
3
4
  export const RUN_MARKER = '.rightkit-run.json';
4
5
  export const DEFAULT_RETENTION_BYTES = 20_000_000_000;
@@ -10,9 +11,12 @@ export function resolveManagedRoot(explicit) {
10
11
  return path.resolve(explicit);
11
12
  if (process.env.RIGHTKIT_MANAGED_ROOT?.trim())
12
13
  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';
14
+ const [workstation, volume] = process.platform === 'win32' ? ['D:\\.rightkit-managed', 'D:\\'] : ['/Volumes/D/.rightkit-managed', '/Volumes/D'];
15
+ if (fs.existsSync(volume))
16
+ return path.resolve(workstation);
17
+ // Hosted CI and other machines: runner temp, then the system temp dir.
18
+ const runnerTemp = process.env.RUNNER_TEMP?.trim();
19
+ return path.join(runnerTemp && fs.existsSync(runnerTemp) ? runnerTemp : os.tmpdir(), 'rightkit-managed');
16
20
  }
17
21
  export function safeKey(value, name) {
18
22
  const v = String(value ?? '').trim();
@@ -69,6 +73,14 @@ export function finishManagedRun(run) {
69
73
  const marker = readMarker(markerPath);
70
74
  if (!marker)
71
75
  return undefined;
76
+ const home = ownedAppRoot(root, marker);
77
+ if (home) {
78
+ copyAppTree(path.join(home, 'evidence'), path.join(root, 'evidence', 'app'));
79
+ copyAppTree(path.join(home, 'captures'), path.join(root, 'captures'));
80
+ for (const name of fs.readdirSync(home))
81
+ if (name.endsWith('.log'))
82
+ copyAppTree(path.join(home, name), path.join(root, 'evidence', name));
83
+ }
72
84
  const finished = { ...marker, ownerPid: 0, leaseUntil: 0, completedAt: Date.now() };
73
85
  writeMarker(root, finished);
74
86
  return finished;
@@ -149,6 +161,7 @@ function pruneUnlocked(appRoot, app, policy, report) {
149
161
  if (!safeDirectory(item.root))
150
162
  continue;
151
163
  try {
164
+ removeManagedAppRoot(item.root, item.marker);
152
165
  fs.rmSync(item.root, { recursive: true, force: false });
153
166
  report.removed.push(item.root);
154
167
  removed.add(item.marker.runId);
@@ -226,6 +239,8 @@ function readMarker(file) {
226
239
  return undefined;
227
240
  if (x.completedAt !== undefined && (typeof x.completedAt !== 'number' || !Number.isFinite(x.completedAt) || x.completedAt < 0))
228
241
  return undefined;
242
+ if (x.appRoot !== undefined && typeof x.appRoot !== 'string')
243
+ return undefined;
229
244
  return x;
230
245
  }
231
246
  catch {
@@ -313,3 +328,53 @@ function describeError(error) {
313
328
  }
314
329
  function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
315
330
  function cryptoRandomId() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; }
331
+ /** Short internal paths keep launchd files accessible & service sockets within sockaddr_un. */
332
+ export function appTempDir() {
333
+ return process.platform === 'darwin' ? '/private/tmp' : fs.realpathSync(os.tmpdir());
334
+ }
335
+ export function createManagedAppRoot(run) {
336
+ const parent = path.join(appTempDir(), 'rightkit-qa');
337
+ ensureSafeDirectory(parent);
338
+ const home = fs.mkdtempSync(path.join(parent, `${run.app.slice(0, 16)}-`));
339
+ fs.writeFileSync(path.join(home, '.rightkit-app.json'), JSON.stringify({ runRoot: fs.realpathSync(run.root), app: run.app, runId: run.runId }), { flag: 'wx' });
340
+ run.marker.appRoot = home;
341
+ writeMarker(run.root, run.marker);
342
+ return home;
343
+ }
344
+ function ownedAppRoot(root, marker) {
345
+ const home = marker.appRoot;
346
+ if (!home || !fs.existsSync(home))
347
+ return undefined;
348
+ const parent = path.dirname(home);
349
+ const temp = path.dirname(parent);
350
+ const allowedTemp = temp === appTempDir() || process.platform === 'darwin' && (temp === '/private/tmp' || isInside('/private/var/folders', temp) && path.basename(temp) === 'T');
351
+ if (path.basename(parent) !== 'rightkit-qa' || !allowedTemp || fs.realpathSync(home) !== home || !safeDirectory(home))
352
+ throw new Error('invalid QA app temp root');
353
+ const binding = JSON.parse(fs.readFileSync(path.join(home, '.rightkit-app.json'), 'utf8'));
354
+ if (binding.runRoot !== fs.realpathSync(root) || binding.app !== marker.app || binding.runId !== marker.runId)
355
+ throw new Error('QA app temp ownership mismatch');
356
+ return home;
357
+ }
358
+ export function removeManagedAppRoot(root, marker) {
359
+ const home = ownedAppRoot(root, marker);
360
+ if (home)
361
+ fs.rmSync(home, { recursive: true, force: false });
362
+ }
363
+ function copyAppTree(source, target) {
364
+ const meta = fs.lstatSync(source);
365
+ if (meta.isSymbolicLink())
366
+ throw new Error('symlink in QA app evidence');
367
+ if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink())
368
+ throw new Error('symlink in managed QA evidence');
369
+ if (meta.isDirectory()) {
370
+ ensureSafeDirectory(target);
371
+ for (const name of fs.readdirSync(source))
372
+ copyAppTree(path.join(source, name), path.join(target, name));
373
+ }
374
+ else if (meta.isFile()) {
375
+ ensureSafeDirectory(path.dirname(target));
376
+ fs.copyFileSync(source, target);
377
+ }
378
+ else
379
+ throw new Error('unsupported QA app evidence file');
380
+ }
@@ -4,6 +4,8 @@ export interface QaWorkspace {
4
4
  appKey: string;
5
5
  managedRoot: string;
6
6
  root: string;
7
+ /** Owned app home on internal system temp storage. */
8
+ home: string;
7
9
  /** Isolated user-data directory; pass to the app so it takes its own lock. */
8
10
  dataDir: string;
9
11
  /** Somewhere for screenshots, logs, and other recorded evidence. */
package/dist/workspace.js CHANGED
@@ -17,35 +17,49 @@
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
+ import { createManagedRun, createManagedAppRoot, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
21
21
  export function createQaWorkspace(options) {
22
22
  const appKey = requireText(options.appKey, 'appKey');
23
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
24
  const runId = managed.runId;
25
25
  const root = managed.root;
26
- const dataDir = path.join(root, 'data');
26
+ const home = createManagedAppRoot(managed);
27
+ const dataDir = path.join(home, 'data');
27
28
  const evidenceDir = path.join(root, 'evidence');
28
29
  const captureDir = path.join(root, 'captures');
29
- const tmpDir = path.join(root, 'tmp');
30
+ const tmpDir = path.join(home, 'tmp');
31
+ const cacheDir = path.join(home, 'cache');
32
+ const appEvidenceDir = path.join(home, 'evidence');
33
+ const appCaptureDir = path.join(home, 'captures');
34
+ fs.mkdirSync(appEvidenceDir, { recursive: true });
35
+ fs.mkdirSync(appCaptureDir, { recursive: true });
30
36
  fs.mkdirSync(dataDir, { recursive: true });
31
37
  fs.mkdirSync(evidenceDir, { recursive: true });
32
38
  fs.mkdirSync(captureDir, { recursive: true });
33
39
  fs.mkdirSync(tmpDir, { recursive: true });
40
+ fs.mkdirSync(cacheDir, { recursive: true });
34
41
  return {
35
42
  runId,
36
43
  appKey,
37
44
  managedRoot: path.resolve(options.root ? requireText(options.root, 'root') : resolveManagedRoot()),
38
45
  root,
46
+ home,
39
47
  dataDir,
40
48
  evidenceDir,
41
49
  captureDir,
42
50
  env: {
43
51
  RIGHTKIT_QA_NATIVE: '1',
44
52
  RIGHTKIT_QA_RUN_ID: runId,
45
- RIGHTKIT_RUN_ROOT: root,
53
+ RIGHTKIT_RUN_ROOT: home,
54
+ RIGHTKIT_SUITE_ROOT: path.join(home, 'suite'),
55
+ HOME: home,
56
+ USERPROFILE: home,
57
+ APPDATA: dataDir,
58
+ LOCALAPPDATA: dataDir,
59
+ XDG_CACHE_HOME: cacheDir,
46
60
  RIGHTKIT_QA_DATA_DIR: dataDir,
47
- RIGHTKIT_QA_EVIDENCE_DIR: evidenceDir,
48
- RIGHTKIT_QA_CAPTURE_DIR: captureDir,
61
+ RIGHTKIT_QA_EVIDENCE_DIR: appEvidenceDir,
62
+ RIGHTKIT_QA_CAPTURE_DIR: appCaptureDir,
49
63
  TMPDIR: tmpDir,
50
64
  TMP: tmpDir,
51
65
  TEMP: tmpDir,
@@ -68,13 +82,19 @@ export function disposeQaWorkspace(workspace, options = {}) {
68
82
  const attempts = Math.max(1, options.attempts ?? 5);
69
83
  const retryDelayMs = options.retryDelayMs ?? 200;
70
84
  const sleep = options.sleep ?? sleepSync;
71
- const targets = options.keepEvidence ? [workspace.dataDir] : [workspace.root];
85
+ const targets = options.keepEvidence ? [workspace.home] : [workspace.home, workspace.root];
72
86
  const report = { removed: [], retained: [] };
73
- finishManagedRun(workspace.root);
87
+ try {
88
+ finishManagedRun(workspace.root);
89
+ }
90
+ catch (error) {
91
+ report.retained.push({ path: workspace.root, reason: describeError(error) });
92
+ return report;
93
+ }
74
94
  for (const target of targets) {
75
- // Only ever remove inside the workspace root we created.
95
+ // Only remove the managed run or its registered temp companion.
76
96
  const resolved = path.resolve(target);
77
- if (resolved !== path.resolve(workspace.root) && !isInside(workspace.root, resolved)) {
97
+ if (resolved !== path.resolve(workspace.root) && resolved !== path.resolve(workspace.home)) {
78
98
  report.retained.push({ path: resolved, reason: 'outside the QA workspace root' });
79
99
  continue;
80
100
  }
@@ -97,8 +117,14 @@ export function disposeQaWorkspace(workspace, options = {}) {
97
117
  }
98
118
  if (lastError === undefined && !fs.existsSync(resolved))
99
119
  report.removed.push(resolved);
100
- else
120
+ else {
101
121
  report.retained.push({ path: resolved, reason: describeError(lastError) });
122
+ if (resolved === path.resolve(workspace.home)) {
123
+ if (!options.keepEvidence)
124
+ report.retained.push({ path: workspace.root, reason: 'app temp cleanup pending' });
125
+ break;
126
+ }
127
+ }
102
128
  }
103
129
  return report;
104
130
  }
@@ -130,10 +156,6 @@ export function pruneAbandonedWorkspaces(root, { olderThanMs, now = Date.now(),
130
156
  }
131
157
  return report;
132
158
  }
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
159
  function describeError(error) {
138
160
  if (error === undefined)
139
161
  return 'still present after removal reported success';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/qa",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
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",