@rightkit/qa 0.3.0 → 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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/storage.d.ts +56 -0
- package/dist/storage.js +380 -0
- package/dist/workspace.d.ts +12 -1
- package/dist/workspace.js +63 -41
- package/package.json +3 -3
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/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
/** Owned internal-temp companion; validated against its binding before cleanup. */
|
|
14
|
+
appRoot?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ManagedRun {
|
|
17
|
+
root: string;
|
|
18
|
+
app: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
marker: RunMarker;
|
|
21
|
+
}
|
|
22
|
+
export interface RetentionPolicy {
|
|
23
|
+
maxBytes?: number;
|
|
24
|
+
keepCount?: number;
|
|
25
|
+
youngMs?: number;
|
|
26
|
+
now?: number;
|
|
27
|
+
keepRunIds?: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface RetentionReport {
|
|
30
|
+
removed: string[];
|
|
31
|
+
retained: Array<{
|
|
32
|
+
path: string;
|
|
33
|
+
reason: string;
|
|
34
|
+
}>;
|
|
35
|
+
totalBytes: number;
|
|
36
|
+
capBytes: number;
|
|
37
|
+
overCapBytes: number;
|
|
38
|
+
blockedBytes: number;
|
|
39
|
+
unknownBytes: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function resolveManagedRoot(explicit?: string): string;
|
|
42
|
+
export declare function safeKey(value: string, name: string): string;
|
|
43
|
+
export declare function runRoot(managedRoot: string, app: string, runId: string): string;
|
|
44
|
+
export declare function createManagedRun(options: {
|
|
45
|
+
managedRoot?: string;
|
|
46
|
+
app: string;
|
|
47
|
+
runId?: string;
|
|
48
|
+
leaseMs?: number;
|
|
49
|
+
retention?: RetentionPolicy;
|
|
50
|
+
}): ManagedRun;
|
|
51
|
+
export declare function finishManagedRun(run: ManagedRun | string): RunMarker | undefined;
|
|
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
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export const RUN_MARKER = '.rightkit-run.json';
|
|
5
|
+
export const DEFAULT_RETENTION_BYTES = 20_000_000_000;
|
|
6
|
+
export const DEFAULT_YOUNG_MS = 24 * 60 * 60_000;
|
|
7
|
+
export const DEFAULT_KEEP_COUNT = 5;
|
|
8
|
+
const LOCK_WAIT_MS = 5_000;
|
|
9
|
+
export function resolveManagedRoot(explicit) {
|
|
10
|
+
if (explicit?.trim())
|
|
11
|
+
return path.resolve(explicit);
|
|
12
|
+
if (process.env.RIGHTKIT_MANAGED_ROOT?.trim())
|
|
13
|
+
return path.resolve(process.env.RIGHTKIT_MANAGED_ROOT);
|
|
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');
|
|
20
|
+
}
|
|
21
|
+
export function safeKey(value, name) {
|
|
22
|
+
const v = String(value ?? '').trim();
|
|
23
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v))
|
|
24
|
+
throw new Error(`unsafe QA ${name}: ${value}`);
|
|
25
|
+
return v;
|
|
26
|
+
}
|
|
27
|
+
export function runRoot(managedRoot, app, runId) {
|
|
28
|
+
const root = path.resolve(managedRoot);
|
|
29
|
+
const appKey = safeKey(app, 'app');
|
|
30
|
+
const id = safeKey(runId, 'run id');
|
|
31
|
+
const result = path.join(root, 'runs', appKey, id);
|
|
32
|
+
if (!isInside(path.join(root, 'runs', appKey), result))
|
|
33
|
+
throw new Error('QA run path escapes managed root');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
export function createManagedRun(options) {
|
|
37
|
+
const managedRoot = resolveManagedRoot(options.managedRoot);
|
|
38
|
+
const app = safeKey(options.app, 'app');
|
|
39
|
+
const runId = safeKey(options.runId ?? cryptoRandomId(), 'run id');
|
|
40
|
+
const appRoot = path.join(managedRoot, 'runs', app);
|
|
41
|
+
withRetentionLock(appRoot, () => {
|
|
42
|
+
ensureSafeDirectory(managedRoot);
|
|
43
|
+
ensureSafeDirectory(path.join(managedRoot, 'runs'));
|
|
44
|
+
ensureSafeDirectory(appRoot);
|
|
45
|
+
const retention = { now: Date.now(), ...(options.retention ?? {}) };
|
|
46
|
+
const report = { removed: [], retained: [], totalBytes: 0, capBytes: DEFAULT_RETENTION_BYTES, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
|
|
47
|
+
pruneUnlocked(appRoot, app, retention, report);
|
|
48
|
+
if (report.overCapBytes > 0)
|
|
49
|
+
throw new Error(`QA retention cap is blocked by ${report.overCapBytes} bytes of protected or unknown data`);
|
|
50
|
+
const root = runRoot(managedRoot, app, runId);
|
|
51
|
+
if (fs.existsSync(root))
|
|
52
|
+
throw new Error(`QA run already exists: ${root}`);
|
|
53
|
+
fs.mkdirSync(root, { recursive: true });
|
|
54
|
+
const marker = {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
app,
|
|
57
|
+
runId,
|
|
58
|
+
createdAt: Date.now(),
|
|
59
|
+
ownerPid: process.pid,
|
|
60
|
+
leaseUntil: Date.now() + Math.max(1, options.leaseMs ?? DEFAULT_YOUNG_MS),
|
|
61
|
+
};
|
|
62
|
+
writeMarker(root, marker);
|
|
63
|
+
});
|
|
64
|
+
const root = runRoot(managedRoot, app, runId);
|
|
65
|
+
const marker = readMarker(path.join(root, RUN_MARKER));
|
|
66
|
+
if (!marker)
|
|
67
|
+
throw new Error(`QA run marker was not created: ${root}`);
|
|
68
|
+
return { root, app, runId, marker };
|
|
69
|
+
}
|
|
70
|
+
export function finishManagedRun(run) {
|
|
71
|
+
const root = typeof run === 'string' ? run : run.root;
|
|
72
|
+
const markerPath = path.join(path.resolve(root), RUN_MARKER);
|
|
73
|
+
const marker = readMarker(markerPath);
|
|
74
|
+
if (!marker)
|
|
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
|
+
}
|
|
84
|
+
const finished = { ...marker, ownerPid: 0, leaseUntil: 0, completedAt: Date.now() };
|
|
85
|
+
writeMarker(root, finished);
|
|
86
|
+
return finished;
|
|
87
|
+
}
|
|
88
|
+
export function pruneManagedRuns(managedRoot, app, policy = {}) {
|
|
89
|
+
const root = resolveManagedRoot(managedRoot);
|
|
90
|
+
const appKey = safeKey(app, 'app');
|
|
91
|
+
const appRoot = path.join(root, 'runs', appKey);
|
|
92
|
+
const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
|
|
93
|
+
const report = { removed: [], retained: [], totalBytes: 0, capBytes: cap, overCapBytes: 0, blockedBytes: 0, unknownBytes: 0 };
|
|
94
|
+
if (!safeDirectory(appRoot))
|
|
95
|
+
return report;
|
|
96
|
+
return withRetentionLock(appRoot, () => pruneUnlocked(appRoot, appKey, policy, report));
|
|
97
|
+
}
|
|
98
|
+
function pruneUnlocked(appRoot, app, policy, report) {
|
|
99
|
+
const now = policy.now ?? Date.now();
|
|
100
|
+
const cap = Number.isFinite(policy.maxBytes) && policy.maxBytes >= 0 ? policy.maxBytes : DEFAULT_RETENTION_BYTES;
|
|
101
|
+
report.capBytes = cap;
|
|
102
|
+
const keepCount = Math.max(0, Math.floor(policy.keepCount ?? DEFAULT_KEEP_COUNT));
|
|
103
|
+
const youngMs = Math.max(0, policy.youngMs ?? DEFAULT_YOUNG_MS);
|
|
104
|
+
const candidates = [];
|
|
105
|
+
let total = 0;
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = fs.readdirSync(appRoot, { withFileTypes: true });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return report;
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (entry.name.startsWith('.'))
|
|
115
|
+
continue;
|
|
116
|
+
if (entry.isSymbolicLink()) {
|
|
117
|
+
report.retained.push({ path: path.join(appRoot, entry.name), reason: 'symlink skipped' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!entry.isDirectory())
|
|
121
|
+
continue;
|
|
122
|
+
const runPath = path.join(appRoot, entry.name);
|
|
123
|
+
if (!isInside(appRoot, runPath))
|
|
124
|
+
continue;
|
|
125
|
+
const marker = readMarker(path.join(runPath, RUN_MARKER));
|
|
126
|
+
if (!marker || marker.app !== app || marker.runId !== entry.name) {
|
|
127
|
+
const bytes = directoryBytes(runPath);
|
|
128
|
+
if (bytes !== undefined)
|
|
129
|
+
report.unknownBytes += bytes;
|
|
130
|
+
report.retained.push({ path: runPath, reason: 'unknown or malformed run metadata' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const bytes = directoryBytes(runPath);
|
|
134
|
+
if (bytes === undefined) {
|
|
135
|
+
report.retained.push({ path: runPath, reason: 'symlink or unreadable content skipped' });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const active = marker.ownerPid > 0 && isProcessLive(marker.ownerPid) || marker.leaseUntil > now;
|
|
139
|
+
total += bytes;
|
|
140
|
+
candidates.push({ root: runPath, marker, bytes, active });
|
|
141
|
+
}
|
|
142
|
+
candidates.sort((a, b) => b.marker.createdAt - a.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
|
|
143
|
+
const countProtected = new Set(candidates.slice(0, keepCount).map((x) => x.marker.runId));
|
|
144
|
+
const protectedIds = new Set();
|
|
145
|
+
const callerProtected = new Set(policy.keepRunIds ?? []);
|
|
146
|
+
for (const item of candidates) {
|
|
147
|
+
if (now - item.marker.createdAt < youngMs)
|
|
148
|
+
protectedIds.add(item.marker.runId);
|
|
149
|
+
if (item.active)
|
|
150
|
+
protectedIds.add(item.marker.runId);
|
|
151
|
+
if (callerProtected.has(item.marker.runId))
|
|
152
|
+
protectedIds.add(item.marker.runId);
|
|
153
|
+
}
|
|
154
|
+
const old = candidates
|
|
155
|
+
.filter((x) => !protectedIds.has(x.marker.runId) && (total > cap || !countProtected.has(x.marker.runId)))
|
|
156
|
+
.sort((a, b) => a.marker.createdAt - b.marker.createdAt || a.marker.runId.localeCompare(b.marker.runId));
|
|
157
|
+
const removed = new Set();
|
|
158
|
+
for (const item of old) {
|
|
159
|
+
if (total <= cap && countProtected.has(item.marker.runId))
|
|
160
|
+
continue;
|
|
161
|
+
if (!safeDirectory(item.root))
|
|
162
|
+
continue;
|
|
163
|
+
try {
|
|
164
|
+
removeManagedAppRoot(item.root, item.marker);
|
|
165
|
+
fs.rmSync(item.root, { recursive: true, force: false });
|
|
166
|
+
report.removed.push(item.root);
|
|
167
|
+
removed.add(item.marker.runId);
|
|
168
|
+
total -= item.bytes;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
report.retained.push({ path: item.root, reason: describeError(error) });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const item of candidates) {
|
|
175
|
+
if (!removed.has(item.marker.runId) && (protectedIds.has(item.marker.runId) || (total <= cap && countProtected.has(item.marker.runId))))
|
|
176
|
+
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') });
|
|
177
|
+
}
|
|
178
|
+
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);
|
|
179
|
+
report.totalBytes = total + report.unknownBytes;
|
|
180
|
+
report.overCapBytes = Math.max(0, report.totalBytes - report.capBytes);
|
|
181
|
+
return report;
|
|
182
|
+
}
|
|
183
|
+
function withRetentionLock(appRoot, body) {
|
|
184
|
+
ensureSafeDirectory(path.dirname(appRoot));
|
|
185
|
+
ensureSafeDirectory(appRoot);
|
|
186
|
+
const lock = path.join(appRoot, '.retention-lock');
|
|
187
|
+
const owner = path.join(lock, 'owner.json');
|
|
188
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
189
|
+
while (true) {
|
|
190
|
+
try {
|
|
191
|
+
fs.mkdirSync(lock);
|
|
192
|
+
fs.writeFileSync(owner, JSON.stringify({ pid: process.pid, createdAt: Date.now() }), 'utf8');
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code !== 'EEXIST')
|
|
197
|
+
throw error;
|
|
198
|
+
let pid;
|
|
199
|
+
try {
|
|
200
|
+
pid = JSON.parse(fs.readFileSync(owner, 'utf8')).pid;
|
|
201
|
+
}
|
|
202
|
+
catch { /* uncertain owner: wait */ }
|
|
203
|
+
if (typeof pid === 'number' && pid > 0 && !isProcessLive(pid)) {
|
|
204
|
+
try {
|
|
205
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
catch { /* retry */ }
|
|
208
|
+
}
|
|
209
|
+
else if (Date.now() >= deadline) {
|
|
210
|
+
throw new Error(`QA retention lock is held: ${lock}`);
|
|
211
|
+
}
|
|
212
|
+
if (Date.now() < deadline)
|
|
213
|
+
sleepSync(20);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
return body();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
try {
|
|
221
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
catch { /* best effort */ }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function writeMarker(root, marker) {
|
|
227
|
+
const target = path.join(root, RUN_MARKER);
|
|
228
|
+
const temp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
229
|
+
fs.writeFileSync(temp, `${JSON.stringify(marker)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
230
|
+
fs.renameSync(temp, target);
|
|
231
|
+
}
|
|
232
|
+
function readMarker(file) {
|
|
233
|
+
try {
|
|
234
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
235
|
+
if (!raw || typeof raw !== 'object')
|
|
236
|
+
return undefined;
|
|
237
|
+
const x = raw;
|
|
238
|
+
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)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (x.completedAt !== undefined && (typeof x.completedAt !== 'number' || !Number.isFinite(x.completedAt) || x.completedAt < 0))
|
|
241
|
+
return undefined;
|
|
242
|
+
if (x.appRoot !== undefined && typeof x.appRoot !== 'string')
|
|
243
|
+
return undefined;
|
|
244
|
+
return x;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function directoryBytes(root) {
|
|
251
|
+
let total = 0;
|
|
252
|
+
const walk = (dir) => {
|
|
253
|
+
let entries;
|
|
254
|
+
try {
|
|
255
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
for (const entry of entries) {
|
|
261
|
+
const p = path.join(dir, entry.name);
|
|
262
|
+
if (entry.isSymbolicLink())
|
|
263
|
+
return false;
|
|
264
|
+
if (entry.isDirectory()) {
|
|
265
|
+
if (!walk(p))
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
else if (entry.isFile()) {
|
|
269
|
+
try {
|
|
270
|
+
total += fs.statSync(p).size;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return true;
|
|
280
|
+
};
|
|
281
|
+
return walk(root) ? total : undefined;
|
|
282
|
+
}
|
|
283
|
+
function safeDirectory(dir) {
|
|
284
|
+
try {
|
|
285
|
+
return fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink();
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function ensureSafeDirectory(dir) {
|
|
292
|
+
const absolute = path.resolve(dir);
|
|
293
|
+
const parts = absolute.split(path.sep);
|
|
294
|
+
let current = parts[0] || path.sep;
|
|
295
|
+
for (const part of parts.slice(1)) {
|
|
296
|
+
if (!part)
|
|
297
|
+
continue;
|
|
298
|
+
current = path.join(current, part);
|
|
299
|
+
try {
|
|
300
|
+
if (fs.lstatSync(current).isSymbolicLink())
|
|
301
|
+
throw new Error(`refusing symlink in managed path: ${current}`);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (error.code !== 'ENOENT')
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
fs.mkdirSync(absolute, { recursive: true });
|
|
309
|
+
}
|
|
310
|
+
function isInside(root, candidate) {
|
|
311
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
312
|
+
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
|
|
313
|
+
}
|
|
314
|
+
function isProcessLive(pid) {
|
|
315
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
316
|
+
return false;
|
|
317
|
+
try {
|
|
318
|
+
process.kill(pid, 0);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return error.code === 'EPERM';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function describeError(error) {
|
|
326
|
+
const x = error;
|
|
327
|
+
return x?.code ? `${x.code}: ${x.message}` : String(error?.message ?? error);
|
|
328
|
+
}
|
|
329
|
+
function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
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
|
@@ -1,12 +1,20 @@
|
|
|
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;
|
|
7
|
+
/** Owned app home on internal system temp storage. */
|
|
8
|
+
home: string;
|
|
4
9
|
/** Isolated user-data directory; pass to the app so it takes its own lock. */
|
|
5
10
|
dataDir: string;
|
|
6
11
|
/** Somewhere for screenshots, logs, and other recorded evidence. */
|
|
7
12
|
evidenceDir: string;
|
|
13
|
+
/** Managed capture output directory. */
|
|
14
|
+
captureDir: string;
|
|
8
15
|
/** Environment overrides that point the app at this workspace. */
|
|
9
16
|
env: Record<string, string>;
|
|
17
|
+
marker: ManagedRun['marker'];
|
|
10
18
|
}
|
|
11
19
|
export interface DisposeReport {
|
|
12
20
|
removed: string[];
|
|
@@ -16,9 +24,12 @@ export interface DisposeReport {
|
|
|
16
24
|
}>;
|
|
17
25
|
}
|
|
18
26
|
export interface CreateQaWorkspaceOptions {
|
|
19
|
-
root
|
|
27
|
+
/** Managed root override; tests may provide a temporary directory. */
|
|
28
|
+
root?: string;
|
|
20
29
|
runId?: string;
|
|
21
30
|
appKey: string;
|
|
31
|
+
leaseMs?: number;
|
|
32
|
+
retention?: import('./storage.js').RetentionPolicy;
|
|
22
33
|
}
|
|
23
34
|
export declare function createQaWorkspace(options: CreateQaWorkspaceOptions): QaWorkspace;
|
|
24
35
|
export interface DisposeOptions {
|
package/dist/workspace.js
CHANGED
|
@@ -17,26 +17,52 @@
|
|
|
17
17
|
import crypto from 'node:crypto';
|
|
18
18
|
import fs from 'node:fs';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
+
import { createManagedRun, createManagedAppRoot, finishManagedRun, pruneManagedRuns, resolveManagedRoot, } from './storage.js';
|
|
20
21
|
export function createQaWorkspace(options) {
|
|
21
22
|
const appKey = requireText(options.appKey, 'appKey');
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const dataDir = path.join(
|
|
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
|
+
const home = createManagedAppRoot(managed);
|
|
27
|
+
const dataDir = path.join(home, 'data');
|
|
27
28
|
const evidenceDir = path.join(root, 'evidence');
|
|
29
|
+
const captureDir = path.join(root, 'captures');
|
|
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 });
|
|
28
36
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
29
37
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
38
|
+
fs.mkdirSync(captureDir, { recursive: true });
|
|
39
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
40
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
30
41
|
return {
|
|
31
42
|
runId,
|
|
43
|
+
appKey,
|
|
44
|
+
managedRoot: path.resolve(options.root ? requireText(options.root, 'root') : resolveManagedRoot()),
|
|
32
45
|
root,
|
|
46
|
+
home,
|
|
33
47
|
dataDir,
|
|
34
48
|
evidenceDir,
|
|
49
|
+
captureDir,
|
|
35
50
|
env: {
|
|
36
51
|
RIGHTKIT_QA_NATIVE: '1',
|
|
37
52
|
RIGHTKIT_QA_RUN_ID: runId,
|
|
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,
|
|
38
60
|
RIGHTKIT_QA_DATA_DIR: dataDir,
|
|
39
|
-
RIGHTKIT_QA_EVIDENCE_DIR:
|
|
61
|
+
RIGHTKIT_QA_EVIDENCE_DIR: appEvidenceDir,
|
|
62
|
+
RIGHTKIT_QA_CAPTURE_DIR: appCaptureDir,
|
|
63
|
+
TMPDIR: tmpDir,
|
|
64
|
+
TMP: tmpDir,
|
|
65
|
+
TEMP: tmpDir,
|
|
40
66
|
// Tauri/webview honour these for profile isolation; setting all three keeps
|
|
41
67
|
// the run off the user's real profile regardless of which the app reads.
|
|
42
68
|
XDG_DATA_HOME: dataDir,
|
|
@@ -44,6 +70,7 @@ export function createQaWorkspace(options) {
|
|
|
44
70
|
WEBVIEW2_USER_DATA_FOLDER: path.join(dataDir, 'webview2'),
|
|
45
71
|
[`${appKey.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_DATA_DIR`]: dataDir,
|
|
46
72
|
},
|
|
73
|
+
marker: managed.marker,
|
|
47
74
|
};
|
|
48
75
|
}
|
|
49
76
|
/**
|
|
@@ -55,12 +82,19 @@ export function disposeQaWorkspace(workspace, options = {}) {
|
|
|
55
82
|
const attempts = Math.max(1, options.attempts ?? 5);
|
|
56
83
|
const retryDelayMs = options.retryDelayMs ?? 200;
|
|
57
84
|
const sleep = options.sleep ?? sleepSync;
|
|
58
|
-
const targets = options.keepEvidence ? [workspace.
|
|
85
|
+
const targets = options.keepEvidence ? [workspace.home] : [workspace.home, workspace.root];
|
|
59
86
|
const report = { removed: [], retained: [] };
|
|
87
|
+
try {
|
|
88
|
+
finishManagedRun(workspace.root);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
report.retained.push({ path: workspace.root, reason: describeError(error) });
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
60
94
|
for (const target of targets) {
|
|
61
|
-
// Only
|
|
95
|
+
// Only remove the managed run or its registered temp companion.
|
|
62
96
|
const resolved = path.resolve(target);
|
|
63
|
-
if (resolved !== path.resolve(workspace.root) &&
|
|
97
|
+
if (resolved !== path.resolve(workspace.root) && resolved !== path.resolve(workspace.home)) {
|
|
64
98
|
report.retained.push({ path: resolved, reason: 'outside the QA workspace root' });
|
|
65
99
|
continue;
|
|
66
100
|
}
|
|
@@ -83,8 +117,14 @@ export function disposeQaWorkspace(workspace, options = {}) {
|
|
|
83
117
|
}
|
|
84
118
|
if (lastError === undefined && !fs.existsSync(resolved))
|
|
85
119
|
report.removed.push(resolved);
|
|
86
|
-
else
|
|
120
|
+
else {
|
|
87
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
|
+
}
|
|
88
128
|
}
|
|
89
129
|
return report;
|
|
90
130
|
}
|
|
@@ -93,47 +133,29 @@ export function disposeQaWorkspace(workspace, options = {}) {
|
|
|
93
133
|
* workspace's own `runs` directory and to entries older than `olderThanMs`, so a
|
|
94
134
|
* concurrent run is never removed out from under itself.
|
|
95
135
|
*/
|
|
96
|
-
export function pruneAbandonedWorkspaces(root, { olderThanMs
|
|
97
|
-
const
|
|
136
|
+
export function pruneAbandonedWorkspaces(root, { olderThanMs, now = Date.now(), keep = [] } = {}) {
|
|
137
|
+
const managedRoot = path.resolve(root);
|
|
98
138
|
const report = { removed: [], retained: [] };
|
|
99
|
-
|
|
139
|
+
const runsRoot = path.join(managedRoot, 'runs');
|
|
140
|
+
let apps;
|
|
100
141
|
try {
|
|
101
|
-
|
|
142
|
+
apps = fs.readdirSync(runsRoot, { withFileTypes: true });
|
|
102
143
|
}
|
|
103
144
|
catch {
|
|
104
145
|
return report;
|
|
105
146
|
}
|
|
106
|
-
for (const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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) });
|
|
147
|
+
for (const app of apps) {
|
|
148
|
+
if (app.isDirectory() && !app.isSymbolicLink()) {
|
|
149
|
+
const policy = olderThanMs === undefined
|
|
150
|
+
? { now, keepRunIds: keep }
|
|
151
|
+
: { now, youngMs: olderThanMs, keepCount: 0, maxBytes: 0, keepRunIds: keep };
|
|
152
|
+
const sub = pruneManagedRuns(managedRoot, app.name, policy);
|
|
153
|
+
report.removed.push(...sub.removed);
|
|
154
|
+
report.retained.push(...sub.retained);
|
|
129
155
|
}
|
|
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.
|
|
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",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"sideEffects": false,
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@rightkit/qa-chrome": "0.1.
|
|
27
|
+
"@rightkit/qa-chrome": "0.1.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@types/node": "^24.0.0",
|
|
@@ -43,6 +43,6 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc -p tsconfig.json",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
-
"test": "vitest run"
|
|
46
|
+
"test": "vitest run && node --test public-storage.test.mjs"
|
|
47
47
|
}
|
|
48
48
|
}
|