@hasna/recordings 0.4.0 → 0.5.0
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 +56 -6
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +28 -7
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/index.js +27 -6
- package/dist/mcp/index.js +27 -6
- package/dist/server/index.js +27 -6
- package/dist/storage.js +27 -6
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +27 -6
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/macos_artifact.ts +40 -33
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +228 -60
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +13 -3
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +441 -72
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +115 -2
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +93 -21
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/build.sh +2 -1
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
|
@@ -1,174 +1,251 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Runs the app's own Linux-CI-partitioned suite — `test:gated`, which covers
|
|
6
|
-
* every discovered `*.test.ts` (the quarantine file is empty by design) — and
|
|
7
|
-
* fails on ANY failure.
|
|
8
|
-
*
|
|
9
|
-
* The allowlist is SELF-INVALIDATING, the same property as
|
|
10
|
-
* `.github/linux-quarantine.txt`: if an allowlisted failure stops failing
|
|
11
|
-
* (the owning lane lands its fix, or a quarantined package ages out of the
|
|
12
|
-
* 7-day window), the gate exits 1 and demands the entry be removed. Any
|
|
13
|
-
* failure outside the allowlist fails the gate.
|
|
14
|
-
*
|
|
15
|
-
* Allowlist history (both entries removed 2026-08-23 by the 0.3.9 release
|
|
16
|
-
* lane, publish-all-recordings, per this gate's own self-invalidation
|
|
17
|
-
* contract — each failure stopped failing at head 15b6181c6):
|
|
18
|
-
* - service-contract-manifest.test.ts (credential_seam_compliance): the
|
|
19
|
-
* contracts-surface migration landed in hasna/apps PR #957
|
|
20
|
-
* (resolveStorageClient now imported from @hasna/contracts/client), so
|
|
21
|
-
* the conformance check passes; the entry's own reason said "Remove this
|
|
22
|
-
* entry when the lane lands."
|
|
23
|
-
* - native-app-companion-contract.test.ts (release supply-chain policy):
|
|
24
|
-
* the companion build's --minimum-release-age=604800 quarantine no longer
|
|
25
|
-
* bites because @hasna/contracts is on the fleet minimumReleaseAgeExcludes
|
|
26
|
-
* list (the sanctioned mechanism for fresh publishes); the test passes.
|
|
27
|
-
* Both removals are the gate's designed behaviour, not a weakening: any
|
|
28
|
-
* future failure now fails the gate with zero allowlisted exceptions.
|
|
29
|
-
*
|
|
30
|
-
* The gate FAILS CLOSED on abnormal termination: a non-zero suite exit is
|
|
31
|
-
* acceptable only when it is fully explained by allowlisted failures — the
|
|
32
|
-
* runner completed (its summary line is present), every parsed failure is
|
|
33
|
-
* allowlisted, at least one allowlisted failure fired, and the runner's
|
|
34
|
-
* reported failure count equals the parsed count. A crash, signal kill,
|
|
35
|
-
* timeout, or runner error that produces a non-zero exit without a complete
|
|
36
|
-
* summary fails the gate (remediation cycle 2 of PR hasna/apps#826).
|
|
2
|
+
/** Run every selected manifest test once, using each Darwin fixture's required boundary.
|
|
3
|
+
* There are no allowed failures. Missing/empty reports, crashes (including exit-zero
|
|
4
|
+
* early exits), unexpected files and overlapping or incomplete selections fail closed.
|
|
37
5
|
*/
|
|
38
6
|
import { spawnSync } from "node:child_process";
|
|
39
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
|
40
8
|
import { tmpdir } from "node:os";
|
|
41
9
|
import { join } from "node:path";
|
|
42
10
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const res = spawnSync(process.execPath, argv, { encoding: "utf8", env: { ...process.env, ...env } });
|
|
49
|
-
return { status: res.status ?? -1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" };
|
|
11
|
+
export interface SuiteGroup {
|
|
12
|
+
id: string;
|
|
13
|
+
files: string[];
|
|
14
|
+
runner: "bun" | "publication" | "recorder";
|
|
15
|
+
pattern?: string;
|
|
50
16
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
17
|
+
export interface GroupReport {
|
|
18
|
+
id: string;
|
|
19
|
+
status: number | null;
|
|
20
|
+
signal?: string | null;
|
|
21
|
+
error?: string;
|
|
22
|
+
output: string;
|
|
23
|
+
junit: string;
|
|
57
24
|
}
|
|
25
|
+
const LIFECYCLE = "src/__tests__/macos-app-lifecycle.test.ts";
|
|
26
|
+
const PUBLICATION = "src/__tests__/release-output-publication-contract.test.ts";
|
|
27
|
+
const RECORDER = "src/__tests__/recorder.test.ts";
|
|
28
|
+
const PUBLICATION_NAMES = "(?:macOS finalized artifact installer|release output publication contract)(?:\\s|$)";
|
|
58
29
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
30
|
+
export function planReleaseSuite(files: string[], platform: string): SuiteGroup[] {
|
|
31
|
+
if (!files.length || new Set(files).size !== files.length || files.some(f => !/^src\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_.-]+\.test\.ts$/.test(f))) {
|
|
32
|
+
throw new Error("empty, duplicate or unsafe manifest-gated files");
|
|
33
|
+
}
|
|
34
|
+
const recorder: SuiteGroup[] = files.includes(RECORDER) ? [{ id: "recorder", files: [RECORDER], runner: "recorder" }] : [];
|
|
35
|
+
const remaining = files.filter(f => f !== RECORDER);
|
|
36
|
+
if (platform !== "darwin") return [...recorder, ...(remaining.length ? [{ id: "ordinary", files: remaining, runner: "bun" as const }] : [])];
|
|
37
|
+
for (const file of [LIFECYCLE, PUBLICATION, RECORDER]) if (!files.includes(file)) throw new Error(`required Darwin fixture file missing: ${file}`);
|
|
38
|
+
const ordinary = remaining.filter(f => f !== LIFECYCLE && f !== PUBLICATION);
|
|
39
|
+
if (!ordinary.length) throw new Error("empty ordinary group");
|
|
40
|
+
return [
|
|
41
|
+
...recorder,
|
|
42
|
+
{ id: "publication", files: [LIFECYCLE, PUBLICATION], runner: "publication", pattern: `^${PUBLICATION_NAMES}` },
|
|
43
|
+
{ id: "lifecycle", files: [LIFECYCLE], runner: "bun", pattern: `^(?!${PUBLICATION_NAMES})` },
|
|
44
|
+
{ id: "ordinary", files: ordinary, runner: "bun" },
|
|
45
|
+
];
|
|
64
46
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
47
|
+
interface TestCase { key: string; file: string; name: string; skipped: boolean }
|
|
48
|
+
function fail(message: string): never { throw new Error(message); }
|
|
49
|
+
function entity(text: string): string {
|
|
50
|
+
return text.replace(/&([^;]+);/g, (_, value: string) => {
|
|
51
|
+
const entities: Record<string, string> = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">" };
|
|
52
|
+
if (value in entities) return entities[value]!;
|
|
53
|
+
const number = /^#x[0-9a-f]+$/i.test(value) ? parseInt(value.slice(2), 16) : /^#[0-9]+$/.test(value) ? Number(value.slice(1)) : NaN;
|
|
54
|
+
if (!Number.isInteger(number) || number <= 0 || number > 0x10ffff) fail("invalid JUnit entity");
|
|
55
|
+
return String.fromCodePoint(number);
|
|
56
|
+
});
|
|
69
57
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const logPath = join(logDir, "suite.log");
|
|
74
|
-
const suite = spawnSync(
|
|
75
|
-
process.execPath,
|
|
76
|
-
["test", "--timeout", "120000", ...gatedFiles],
|
|
77
|
-
{
|
|
78
|
-
encoding: "utf8",
|
|
79
|
-
env: { ...process.env, RECORDINGS_TEST_TIMEOUT_MS: "120000" },
|
|
80
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
81
|
-
},
|
|
82
|
-
);
|
|
83
|
-
const output = (suite.stdout ?? "") + (suite.stderr ?? "");
|
|
84
|
-
try {
|
|
85
|
-
writeFileSync(logPath, output);
|
|
86
|
-
} catch {
|
|
87
|
-
/* log capture is best-effort */
|
|
58
|
+
function count(value: string | undefined): number {
|
|
59
|
+
if (!value || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) fail("missing or invalid JUnit count");
|
|
60
|
+
return Number(value);
|
|
88
61
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
62
|
+
/** Strictly parse the small XML vocabulary emitted by Bun's JUnit reporter. Unlike
|
|
63
|
+
* console output, it includes filtered tests as skipped, providing a registration
|
|
64
|
+
* inventory without executing the same tests again in a discovery pass.
|
|
65
|
+
*/
|
|
66
|
+
function casesFromJUnit(xml: string): TestCase[] {
|
|
67
|
+
const stack: Array<{ tag: string; attrs: Record<string, string> }> = [];
|
|
68
|
+
const cases: TestCase[] = [];
|
|
69
|
+
const occurrences = new Map<string, number>();
|
|
70
|
+
let root: Record<string, string> | undefined;
|
|
71
|
+
let current: TestCase | undefined;
|
|
72
|
+
let closed = false;
|
|
73
|
+
const tokens = /<\?xml[^?]*\?>|<\/?[a-zA-Z]+(?:\s+[a-zA-Z]+="[^"]*")*\s*\/?>|[^<]+/gy;
|
|
74
|
+
let offset = 0;
|
|
75
|
+
while (offset < xml.length) {
|
|
76
|
+
tokens.lastIndex = offset;
|
|
77
|
+
const match = tokens.exec(xml);
|
|
78
|
+
if (!match || match.index !== offset) fail("malformed or truncated JUnit XML");
|
|
79
|
+
const token = match[0]; offset = tokens.lastIndex;
|
|
80
|
+
if (token.startsWith("<?xml")) { if (root) fail("misplaced JUnit declaration"); continue; }
|
|
81
|
+
if (!token.startsWith("<")) { if (token.trim()) fail("unexpected JUnit text"); continue; }
|
|
82
|
+
const tag = /^<\/?([a-zA-Z]+)/.exec(token)![1]!;
|
|
83
|
+
if (token.startsWith("</")) {
|
|
84
|
+
if (!/^<\/[a-zA-Z]+\s*>$/.test(token) || stack.pop()?.tag !== tag) fail("unbalanced JUnit XML");
|
|
85
|
+
if (tag === "testcase") current = undefined;
|
|
86
|
+
if (tag === "testsuites") closed = true;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (closed) fail("data after JUnit completion");
|
|
90
|
+
const attrs: Record<string, string> = {};
|
|
91
|
+
for (const attr of token.matchAll(/\s+([a-zA-Z]+)="([^"]*)"/g)) {
|
|
92
|
+
if (attr[1]! in attrs) fail("duplicate JUnit attribute");
|
|
93
|
+
attrs[attr[1]!] = entity(attr[2]!);
|
|
94
|
+
}
|
|
95
|
+
const parent = stack.at(-1)?.tag;
|
|
96
|
+
if (tag === "testsuites") {
|
|
97
|
+
if (root || parent) fail("duplicate JUnit root");
|
|
98
|
+
root = attrs;
|
|
99
|
+
} else if (tag === "testsuite") {
|
|
100
|
+
if (parent !== "testsuites" && parent !== "testsuite") fail("misplaced JUnit suite");
|
|
101
|
+
} else if (tag === "testcase") {
|
|
102
|
+
if (parent !== "testsuite" || !attrs.file || attrs.name === undefined) fail("invalid JUnit testcase");
|
|
103
|
+
const names = stack.filter(s => s.tag === "testsuite").slice(1).map(s => s.attrs.name ?? fail("unnamed JUnit suite"));
|
|
104
|
+
const file = attrs.file.replace(/^\.\//, "");
|
|
105
|
+
const identity = JSON.stringify([file, names, attrs.name, attrs.line ?? ""]);
|
|
106
|
+
const instance = occurrences.get(identity) ?? 0; occurrences.set(identity, instance + 1);
|
|
107
|
+
current = { file, name: [...names, attrs.name].join(" "), key: `${identity}:${instance}`, skipped: false };
|
|
108
|
+
cases.push(current);
|
|
109
|
+
} else if (tag === "skipped") {
|
|
110
|
+
if (parent !== "testcase" || !current || current.skipped) fail("misplaced or duplicate JUnit skip");
|
|
111
|
+
current.skipped = true;
|
|
112
|
+
} else fail(`unexpected JUnit element: ${tag}`);
|
|
113
|
+
if (!token.endsWith("/>")) stack.push({ tag, attrs });
|
|
114
|
+
else if (tag === "testcase") current = undefined;
|
|
115
|
+
else if (tag === "testsuites") closed = true;
|
|
103
116
|
}
|
|
117
|
+
if (!root || !closed || stack.length || !cases.length) fail("missing, empty or incomplete JUnit report");
|
|
118
|
+
if (count(root.tests) !== cases.length || count(root.failures) !== 0 || count(root.skipped) !== cases.filter(c => c.skipped).length || (root.errors !== undefined && count(root.errors) !== 0)) fail("JUnit totals do not explain the complete run");
|
|
119
|
+
return cases;
|
|
104
120
|
}
|
|
121
|
+
const sorted = (values: Iterable<string>) => [...values].sort();
|
|
122
|
+
const equal = (a: string[], b: string[]) => JSON.stringify(a) === JSON.stringify(b);
|
|
105
123
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
124
|
+
export function validateReleaseRun(groups: SuiteGroup[], reports: GroupReport[]): { passed: number; skipped: number; tests: number; files: number } {
|
|
125
|
+
if (!groups.length || groups.some(g => !g.files.length) || new Set(groups.map(g => g.id)).size !== groups.length || reports.length !== groups.length || new Set(reports.map(r => r.id)).size !== reports.length) fail("missing, repeated or empty release group");
|
|
126
|
+
const inventories = new Map<string, string[]>();
|
|
127
|
+
const owned = new Map<string, number>();
|
|
128
|
+
let passed = 0, skipped = 0;
|
|
129
|
+
for (const group of groups) {
|
|
130
|
+
const report = reports.find(r => r.id === group.id) ?? fail(`missing report: ${group.id}`);
|
|
131
|
+
if (report.status !== 0 || report.signal || report.error) fail(`${group.id}: runner exited abnormally (${report.error ?? report.signal ?? report.status})`);
|
|
132
|
+
const cases = casesFromJUnit(report.junit);
|
|
133
|
+
if (!equal(sorted(new Set(cases.map(c => c.file))), sorted(group.files))) fail(`${group.id}: executed files differ from the planned group`);
|
|
134
|
+
const selector = group.pattern ? new RegExp(group.pattern) : undefined;
|
|
135
|
+
const selected = cases.filter(c => !selector || selector.test(c.name));
|
|
136
|
+
if (!selected.length || group.files.some(f => !selected.some(c => c.file === f))) fail(`${group.id}: empty selected file or group`);
|
|
137
|
+
if (cases.some(c => selector && !selector.test(c.name) && !c.skipped)) fail(`${group.id}: executed a test owned by another group`);
|
|
138
|
+
for (const file of group.files) {
|
|
139
|
+
const inventory = sorted(cases.filter(c => c.file === file).map(c => c.key));
|
|
140
|
+
const previous = inventories.get(file);
|
|
141
|
+
if (previous && !equal(previous, inventory)) fail(`${group.id}: shared file registration inventory changed: ${file}`);
|
|
142
|
+
inventories.set(file, inventory);
|
|
143
|
+
}
|
|
144
|
+
const selectedSkips = selected.filter(c => c.skipped).length;
|
|
145
|
+
const tally = (kind: string) => {
|
|
146
|
+
const values = [...report.output.matchAll(new RegExp(`^\\s*(\\d+) ${kind}\\s*$`, "gm"))];
|
|
147
|
+
return values.length === 1 ? Number(values[0]![1]) : undefined;
|
|
148
|
+
};
|
|
149
|
+
const summaries = [...report.output.matchAll(/^Ran (\d+) tests? across (\d+) files?\./gm)];
|
|
150
|
+
if (summaries.length !== 1 || Number(summaries[0]![1]) !== selected.length || Number(summaries[0]![2]) !== group.files.length || tally("pass") !== selected.length - selectedSkips || (tally("skip") ?? 0) !== selectedSkips || tally("fail") !== 0 || /^\s*[1-9]\d* errors?\s*$/m.test(report.output)) fail(`${group.id}: console completion does not match selected JUnit tests`);
|
|
151
|
+
for (const c of selected) owned.set(c.key, (owned.get(c.key) ?? 0) + 1);
|
|
152
|
+
passed += selected.length - selectedSkips; skipped += selectedSkips;
|
|
118
153
|
}
|
|
154
|
+
for (const inventory of inventories.values()) for (const key of inventory) if (owned.get(key) !== 1) fail("release test must belong to exactly one completed group");
|
|
155
|
+
return { passed, skipped, tests: passed + skipped, files: inventories.size };
|
|
119
156
|
}
|
|
120
157
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
/** Credentials and account/storage selectors never enter a release test process.
|
|
159
|
+
* This is supplemental isolation; Darwin host tools still require the audited OS
|
|
160
|
+
* fixture boundaries (changing HOME cannot isolate defaults, Keychain or TCC).
|
|
161
|
+
*/
|
|
162
|
+
export function groupEnvironment(root: string, inherited: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
163
|
+
for (const directory of ["home", "tmp", "bin"]) mkdirSync(join(root, directory), { mode: 0o700 });
|
|
164
|
+
// Shell children must use this exact runner even when the caller's PATH has a
|
|
165
|
+
// different Bun version. No global bin or existing symlink is modified.
|
|
166
|
+
symlinkSync(process.execPath, join(root, "bin", "bun"));
|
|
167
|
+
return {
|
|
168
|
+
HOME: join(root, "home"), TMPDIR: join(root, "tmp") + "/",
|
|
169
|
+
PATH: `${join(root, "bin")}:${inherited.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin"}`,
|
|
170
|
+
XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"),
|
|
171
|
+
XDG_DATA_HOME: join(root, "data"), NPM_CONFIG_CACHE: join(root, "npm-cache"),
|
|
172
|
+
BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(root, "bun-cache"),
|
|
173
|
+
RECORDINGS_TEST_TIMEOUT_MS: "120000", NO_COLOR: "1", TERM: "dumb",
|
|
174
|
+
...(inherited.CI ? { CI: inherited.CI } : {}),
|
|
175
|
+
...(inherited.LANG ? { LANG: inherited.LANG } : {}),
|
|
176
|
+
};
|
|
128
177
|
}
|
|
129
178
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
// without a complete summary and must fail the gate.
|
|
137
|
-
if (suiteStatus !== 0) {
|
|
138
|
-
const summaryMatch = /Ran (\d+) tests across (\d+) files/.exec(output);
|
|
139
|
-
// The tally is "N pass[,/ ] M fail" in the runner's closing summary. The
|
|
140
|
-
// LAST match is taken (bun prints the tally once, but a naive first match
|
|
141
|
-
// can hit "0.13.3 failed to resolve" -> "3 fail" inside a version string).
|
|
142
|
-
const tallyMatches = [...output.matchAll(/(\d+) pass[\s,/]+(\d+) fail/g)];
|
|
143
|
-
const failCountMatch = tallyMatches.length > 0 ? tallyMatches[tallyMatches.length - 1] : null;
|
|
144
|
-
const reportedFails = failCountMatch ? Number(failCountMatch[2]) : null;
|
|
145
|
-
const anyAllowlistedFired = failPairs.length > 0;
|
|
146
|
-
const complete = summaryMatch !== null && reportedFails !== null && reportedFails === failPairs.length;
|
|
147
|
-
if (!anyAllowlistedFired || !complete) {
|
|
148
|
-
console.error(
|
|
149
|
-
"release-suite-gate: FAIL - suite exited " + suiteStatus +
|
|
150
|
-
" with " + failPairs.length + " parsed failure(s) but the run is incomplete or unexplained:" +
|
|
151
|
-
"\n summary line present: " + (summaryMatch !== null) +
|
|
152
|
-
"\n reported failure count: " + String(reportedFails) +
|
|
153
|
-
"\n parsed failure count: " + failPairs.length,
|
|
154
|
-
);
|
|
155
|
-
console.error("Full suite log: " + logPath);
|
|
156
|
-
process.exit(1);
|
|
157
|
-
}
|
|
179
|
+
export function groupCommand(group: SuiteGroup, junitPath: string): string[] {
|
|
180
|
+
return group.runner === "publication"
|
|
181
|
+
? ["/usr/bin/python3", "-I", "-B", "src/__tests__/helpers/run-publication-fixtures.py", "--bun", process.execPath, "--report-outfile", junitPath]
|
|
182
|
+
: group.runner === "recorder"
|
|
183
|
+
? [process.execPath, "scripts/test-recorder-fixtures.ts", "--junit-output", junitPath]
|
|
184
|
+
: [process.execPath, "test", "--no-orphans", "--timeout", "120000", ...group.files.map(f => `./${f}`), ...(group.pattern ? ["--test-name-pattern", group.pattern] : []), "--reporter=junit", "--reporter-outfile", junitPath];
|
|
158
185
|
}
|
|
159
186
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
|
|
187
|
+
/** Keep the first failing assertions and the runner summary visible in CI even
|
|
188
|
+
* when a disposable runner's complete logs cannot be recovered. Prefix every
|
|
189
|
+
* line so fixture output cannot be interpreted as a workflow command.
|
|
190
|
+
*/
|
|
191
|
+
export function failureDiagnostics(report: GroupReport): string {
|
|
192
|
+
const lines = report.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").split("\n");
|
|
193
|
+
const selected = new Set<number>();
|
|
194
|
+
let failures = 0;
|
|
195
|
+
for (let index = 0; index < lines.length && failures < 8; index++) {
|
|
196
|
+
if (!/^\s*(?:\(fail\)|error:|Error:|AssertionError:|TimeoutError:)/.test(lines[index]!)) continue;
|
|
197
|
+
failures++;
|
|
198
|
+
for (let context = Math.max(0, index - 12); context <= Math.min(lines.length - 1, index + 6); context++) selected.add(context);
|
|
199
|
+
}
|
|
200
|
+
const tailStart = Math.max(0, lines.length - 20);
|
|
201
|
+
const output = [...selected].filter(index => index < tailStart).sort((a, b) => a - b)
|
|
202
|
+
.map(index => "recordings gate | " + lines[index]!.slice(0, 1000)).join("\n").slice(0, 14_000);
|
|
203
|
+
const summary = lines.slice(tailStart).map(line => "recordings gate | " + line.slice(0, 350)).join("\n");
|
|
204
|
+
return `release-suite-gate: ${report.id} status=${report.status} signal=${report.signal ?? "none"}\n`
|
|
205
|
+
+ output + "\n" + summary;
|
|
168
206
|
}
|
|
169
207
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
process.
|
|
208
|
+
export function releaseMain(): number {
|
|
209
|
+
const args = process.argv.slice(2);
|
|
210
|
+
if (new Set(args).size !== args.length || args.some(arg => arg !== "--all" && arg !== "--plan")) fail("usage: release-suite-gate.ts [--all] [--plan]; arbitrary test filters would invalidate coverage");
|
|
211
|
+
const enumeration = (mode: string) => {
|
|
212
|
+
const child = spawnSync(process.execPath, ["scripts/ci-linux-suite.ts", mode], { encoding: "utf8", timeout: 60000 });
|
|
213
|
+
if (child.status !== 0 || child.signal || child.error) fail(`partition ${mode} failed: ${child.stderr || child.stdout}`);
|
|
214
|
+
return child.stdout;
|
|
215
|
+
};
|
|
216
|
+
// --all validates discovery and quarantine metadata itself, while still running
|
|
217
|
+
// when the gated subset is empty. The default gate must retain its empty-set refusal.
|
|
218
|
+
if (!args.includes("--all")) enumeration("--check");
|
|
219
|
+
const groups = planReleaseSuite(enumeration(args.includes("--all") ? "--all" : "--gated").trim().split("\n").filter(Boolean), process.platform);
|
|
220
|
+
if (args.includes("--plan")) { console.log(JSON.stringify(groups, null, 2)); return 0; }
|
|
221
|
+
const logDir = realpathSync(mkdtempSync(join(tmpdir(), "recordings-release-gate-")));
|
|
222
|
+
console.log(`release-suite-gate: reports and logs: ${logDir}`);
|
|
223
|
+
const reports: GroupReport[] = [];
|
|
224
|
+
for (const group of groups) {
|
|
225
|
+
console.log(`release-suite-gate: running ${group.id} (${group.files.length} files)`);
|
|
226
|
+
const junitPath = join(logDir, `${group.id}.xml`);
|
|
227
|
+
const command = groupCommand(group, junitPath);
|
|
228
|
+
const environmentRoot = mkdtempSync(join(logDir, `${group.id}-environment-`));
|
|
229
|
+
const child = spawnSync(command[0]!, command.slice(1), { encoding: "utf8", env: groupEnvironment(environmentRoot), maxBuffer: 128 * 1024 * 1024, timeout: group.runner === "publication" ? 360000 : group.runner === "recorder" ? 60000 : 30 * 60 * 1000 });
|
|
230
|
+
const output = (child.stdout ?? "") + (child.stderr ?? "");
|
|
231
|
+
writeFileSync(join(logDir, `${group.id}.log`), output);
|
|
232
|
+
let junit = ""; try { junit = readFileSync(junitPath, "utf8"); } catch { /* Missing reports fail validation. */ }
|
|
233
|
+
reports.push({ id: group.id, status: child.status, signal: child.signal, error: child.error?.message, output, junit });
|
|
234
|
+
}
|
|
235
|
+
writeFileSync(join(logDir, "suite.log"), reports.map(r => `=== ${r.id} ===\n${r.output}`).join("\n"));
|
|
236
|
+
let result: ReturnType<typeof validateReleaseRun>;
|
|
237
|
+
try { result = validateReleaseRun(groups, reports); }
|
|
238
|
+
catch (error) {
|
|
239
|
+
const failed = reports.filter(report => report.status !== 0 || report.signal || report.error);
|
|
240
|
+
// Exit-zero truncated receipts also need diagnostics. Coverage validation
|
|
241
|
+
// remains authoritative; printing evidence never changes its result.
|
|
242
|
+
for (const report of failed.length ? failed : reports) console.error(failureDiagnostics(report));
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
console.log(`release-suite-gate: PASS - ${result.passed} pass, ${result.skipped} skip, 0 fail; ${result.tests} tests across ${result.files} files, each selected once`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
if (import.meta.main) {
|
|
249
|
+
try { process.exitCode = releaseMain(); }
|
|
250
|
+
catch (error) { console.error(`release-suite-gate: FAIL - ${(error as Error).message}`); process.exitCode = 1; }
|
|
251
|
+
}
|
|
@@ -77,6 +77,27 @@ recordings_tailscale_stat_owner_and_mode() {
|
|
|
77
77
|
esac
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
# macOS does not ship /usr/bin/realpath on every supported host. Resolve with
|
|
81
|
+
# Bash's physical-directory builtins, without a PATH tool or interpreter. The
|
|
82
|
+
# callers still reject leaf symlinks and compare this physical path exactly.
|
|
83
|
+
recordings_tailscale_physical_path() {
|
|
84
|
+
local candidate="$1"
|
|
85
|
+
local parent
|
|
86
|
+
local leaf
|
|
87
|
+
|
|
88
|
+
recordings_validate_tailscale_cli_shape "$candidate" || return 1
|
|
89
|
+
if [ -d "$candidate" ]; then
|
|
90
|
+
(unset CDPATH; builtin cd -P -- "$candidate" && builtin pwd -P)
|
|
91
|
+
return
|
|
92
|
+
fi
|
|
93
|
+
[ -e "$candidate" ] && [ ! -L "$candidate" ] || return 1
|
|
94
|
+
parent="${candidate%/*}"
|
|
95
|
+
leaf="${candidate##*/}"
|
|
96
|
+
[ -n "$parent" ] || parent=/
|
|
97
|
+
parent="$(unset CDPATH; builtin cd -P -- "$parent" && builtin pwd -P)" || return 1
|
|
98
|
+
printf '%s/%s\n' "${parent%/}" "$leaf"
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
recordings_validate_trusted_tailscale_app_cli() {
|
|
81
102
|
local candidate="$1"
|
|
82
103
|
local real_host_kernel="$2"
|
|
@@ -96,7 +117,7 @@ recordings_validate_trusted_tailscale_app_cli() {
|
|
|
96
117
|
echo "Trusted Tailscale CLI must be a canonical non-symlink trusted executable." >&2
|
|
97
118
|
return 1
|
|
98
119
|
fi
|
|
99
|
-
canonical="$(
|
|
120
|
+
canonical="$(recordings_tailscale_physical_path "$candidate" 2>/dev/null)" || {
|
|
100
121
|
echo "Trusted Tailscale CLI could not be resolved canonically." >&2
|
|
101
122
|
return 1
|
|
102
123
|
}
|
|
@@ -145,7 +166,7 @@ recordings_validate_private_tailscale_snapshot_parent() {
|
|
|
145
166
|
echo "Tailscale snapshot parent must be an existing private directory." >&2
|
|
146
167
|
return 1
|
|
147
168
|
fi
|
|
148
|
-
canonical="$(
|
|
169
|
+
canonical="$(recordings_tailscale_physical_path "$snapshot_parent" 2>/dev/null)" || {
|
|
149
170
|
echo "Tailscale snapshot parent could not be resolved canonically." >&2
|
|
150
171
|
return 1
|
|
151
172
|
}
|
|
@@ -270,7 +291,7 @@ recordings_verify_official_tailscale_app_and_cli() {
|
|
|
270
291
|
echo "Trusted Tailscale app must be a canonical non-symlink directory." >&2
|
|
271
292
|
return 1
|
|
272
293
|
fi
|
|
273
|
-
canonical_app="$(
|
|
294
|
+
canonical_app="$(recordings_tailscale_physical_path "$app" 2>/dev/null)" || {
|
|
274
295
|
echo "Trusted Tailscale app could not be resolved canonically." >&2
|
|
275
296
|
return 1
|
|
276
297
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Dispatch
|
|
2
|
+
|
|
3
|
+
/// Bridge synchronous, potentially blocking APIs without occupying a cooperative worker.
|
|
4
|
+
/// The caller still owns cancellation and generation checks: submitted work drains to its
|
|
5
|
+
/// existing API deadline and returns its exact result even if its awaiting task is cancelled.
|
|
6
|
+
/// Operations must not depend on Swift task-local values or task cancellation state.
|
|
7
|
+
enum BlockingOperation {
|
|
8
|
+
private static let queue = DispatchQueue(
|
|
9
|
+
label: "md.recordings.blocking-operations",
|
|
10
|
+
attributes: .concurrent
|
|
11
|
+
)
|
|
12
|
+
static func run<Value: Sendable>(
|
|
13
|
+
qos: DispatchQoS.QoSClass = .userInitiated,
|
|
14
|
+
_ operation: @escaping @Sendable () -> Value
|
|
15
|
+
) async -> Value {
|
|
16
|
+
await withCheckedContinuation { continuation in
|
|
17
|
+
queue.async(qos: DispatchQoS(qosClass: qos, relativePriority: 0)) {
|
|
18
|
+
continuation.resume(returning: operation())
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static func runThrowing<Value: Sendable>(
|
|
24
|
+
qos: DispatchQoS.QoSClass = .userInitiated,
|
|
25
|
+
_ operation: @escaping @Sendable () throws -> Value
|
|
26
|
+
) async throws -> Value {
|
|
27
|
+
try await run(qos: qos) { Result(catching: operation) }.get()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
<key>CFBundleIdentifier</key>
|
|
18
18
|
<string>com.hasna.recordings</string>
|
|
19
19
|
<key>CFBundleVersion</key>
|
|
20
|
-
<string>0.
|
|
20
|
+
<string>0.5.0</string>
|
|
21
21
|
<key>CFBundlePackageType</key>
|
|
22
22
|
<string>APPL</string>
|
|
23
23
|
<key>CFBundleShortVersionString</key>
|
|
24
|
-
<string>0.
|
|
24
|
+
<string>0.5.0</string>
|
|
25
25
|
<key>LSMinimumSystemVersion</key>
|
|
26
26
|
<string>26.0</string>
|
|
27
27
|
<key>NSMicrophoneUsageDescription</key>
|
|
@@ -288,7 +288,7 @@ public final class ProjectStore: ObservableObject {
|
|
|
288
288
|
isReadyForRecording = false
|
|
289
289
|
let registrations: [(String, RecordingsCLI.CanonicalProject)]
|
|
290
290
|
do {
|
|
291
|
-
registrations = try await
|
|
291
|
+
registrations = try await BlockingOperation.runThrowing(qos: .utility) {
|
|
292
292
|
try original.projects.map { project in
|
|
293
293
|
let canonical = try RecordingsCLI.registerProject(
|
|
294
294
|
name: project.name,
|
|
@@ -297,7 +297,7 @@ public final class ProjectStore: ObservableObject {
|
|
|
297
297
|
)
|
|
298
298
|
return (project.id, canonical)
|
|
299
299
|
}
|
|
300
|
-
}
|
|
300
|
+
}
|
|
301
301
|
} catch {
|
|
302
302
|
let message = "Failed to register projects: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
|
|
303
303
|
synchronizationError = message
|
|
@@ -347,9 +347,9 @@ public final class ProjectStore: ObservableObject {
|
|
|
347
347
|
let local = RecProject(name: name, path: path, systemPrompt: systemPrompt, color: color)
|
|
348
348
|
let canonical: RecordingsCLI.CanonicalProject
|
|
349
349
|
do {
|
|
350
|
-
canonical = try await
|
|
350
|
+
canonical = try await BlockingOperation.runThrowing {
|
|
351
351
|
try RecordingsCLI.registerProject(name: local.name, path: local.registrationPath, home: home)
|
|
352
|
-
}
|
|
352
|
+
}
|
|
353
353
|
} catch {
|
|
354
354
|
persistenceError = "Failed to register project: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
|
|
355
355
|
throw error
|