@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 +8 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +68 -3
- package/dist/workspace.d.ts +2 -0
- package/dist/workspace.js +37 -15
- package/package.json +1 -1
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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:
|
|
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:
|
|
48
|
-
RIGHTKIT_QA_CAPTURE_DIR:
|
|
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.
|
|
85
|
+
const targets = options.keepEvidence ? [workspace.home] : [workspace.home, workspace.root];
|
|
72
86
|
const report = { removed: [], retained: [] };
|
|
73
|
-
|
|
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
|
|
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) &&
|
|
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