@hasna/recordings 0.3.13 → 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 +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- 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/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- 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/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- 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/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- 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 +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- 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
- package/src/native/Recordings/App/SidebarView.swift +0 -212
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Describe reviewed npm bytes; registry verification is an explicit read-only step."""
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import pathlib
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tarfile
|
|
12
|
+
import urllib.request
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
PACKAGE = '@hasna/recordings'
|
|
16
|
+
CORE = 'src/native/Recordings/'
|
|
17
|
+
REGISTRY = 'https://registry.npmjs.org/'
|
|
18
|
+
LIMIT = 128 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def digest(data):
|
|
22
|
+
return hashlib.sha256(data).hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_archive(blob):
|
|
26
|
+
if not blob or len(blob) > LIMIT:
|
|
27
|
+
raise ValueError('Archive size is outside the allowed range')
|
|
28
|
+
files = {}
|
|
29
|
+
total = 0
|
|
30
|
+
seen = set()
|
|
31
|
+
with tarfile.open(fileobj=io.BytesIO(blob), mode='r:*') as archive:
|
|
32
|
+
for member in archive:
|
|
33
|
+
parts = member.name.rstrip('/').split('/')
|
|
34
|
+
if (len(seen) >= 20000 or member.name in seen or len(member.name) > 1024
|
|
35
|
+
or parts[0] != 'package' or any(p in ('', '.', '..') for p in parts)
|
|
36
|
+
or '\\' in member.name or any(ord(c) < 32 for c in member.name)
|
|
37
|
+
or not (member.isfile() or member.isdir())):
|
|
38
|
+
raise ValueError('Archive contains an unsafe or duplicate entry')
|
|
39
|
+
seen.add(member.name)
|
|
40
|
+
if member.isdir():
|
|
41
|
+
continue
|
|
42
|
+
total += member.size
|
|
43
|
+
if member.size > LIMIT or total > LIMIT * 2:
|
|
44
|
+
raise ValueError('Expanded archive is too large')
|
|
45
|
+
handle = archive.extractfile(member)
|
|
46
|
+
if handle is None:
|
|
47
|
+
raise ValueError('Archive member cannot be read')
|
|
48
|
+
data = handle.read(member.size + 1)
|
|
49
|
+
if len(data) != member.size:
|
|
50
|
+
raise ValueError('Archive member is truncated')
|
|
51
|
+
files['/'.join(parts[1:])] = (data, member.mode & 0o777)
|
|
52
|
+
return files
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_receipt(blob, revision):
|
|
56
|
+
if not re.fullmatch('[a-f0-9]{40}', revision):
|
|
57
|
+
raise ValueError('A full public source revision is required')
|
|
58
|
+
files = read_archive(blob)
|
|
59
|
+
try:
|
|
60
|
+
package = json.loads(files['package.json'][0])
|
|
61
|
+
version = package['version']
|
|
62
|
+
if package['name'] != PACKAGE or not re.fullmatch(r'\d+\.\d+\.\d+', version):
|
|
63
|
+
raise ValueError('Unexpected package identity')
|
|
64
|
+
manifest = files[CORE + 'Package.swift'][0].decode()
|
|
65
|
+
for required in ('Package.resolved', 'RecordingsLib/RecordingEngine.swift', 'RecordingsLib/RecordingProvider.swift'):
|
|
66
|
+
if CORE + required not in files:
|
|
67
|
+
raise ValueError('Required native source is missing')
|
|
68
|
+
if (not re.search(r'\.library\(\s*name:\s*"RecordingsLib"\s*,\s*targets:\s*\["RecordingsLib"\]', manifest)
|
|
69
|
+
or not re.search(r'swift-tools-version:\s*6\.2\b', manifest)
|
|
70
|
+
or not re.search(r'\.macOS\(\s*\.v26\s*\)', manifest)):
|
|
71
|
+
raise ValueError('Required Swift library or platform declaration is missing')
|
|
72
|
+
except (KeyError, TypeError, UnicodeError, json.JSONDecodeError) as error:
|
|
73
|
+
raise ValueError('Invalid native package metadata') from error
|
|
74
|
+
native = [{'path': path[len(CORE):], 'sha256': digest(data), 'bytes': len(data), 'mode': mode}
|
|
75
|
+
for path, (data, mode) in sorted(files.items()) if path.startswith(CORE)]
|
|
76
|
+
tree = ''.join(f"{f['path']}\0{f['mode']:o}\0{f['bytes']}\0{f['sha256']}\n" for f in native).encode()
|
|
77
|
+
return {
|
|
78
|
+
'schemaVersion': 1, 'kind': 'hasna.recordings.native-core',
|
|
79
|
+
'package': {'name': PACKAGE, 'version': version},
|
|
80
|
+
'source': {'repository': 'https://github.com/hasna/apps', 'revision': revision},
|
|
81
|
+
'distribution': {'status': 'prepared'},
|
|
82
|
+
'archive': {'url': REGISTRY + '@hasna/recordings/-/recordings-' + version + '.tgz',
|
|
83
|
+
'bytes': len(blob), 'sha256': digest(blob),
|
|
84
|
+
'integrity': 'sha512-' + base64.b64encode(hashlib.sha512(blob).digest()).decode()},
|
|
85
|
+
'native': {'packagePath': CORE.rstrip('/'), 'product': 'RecordingsLib',
|
|
86
|
+
'swiftToolsVersion': '6.2', 'minimumMacOS': '26.0',
|
|
87
|
+
'treeSHA256': digest(tree), 'files': native},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class NoRedirects(urllib.request.HTTPRedirectHandler):
|
|
92
|
+
def redirect_request(self, *args, **kwargs):
|
|
93
|
+
raise ValueError('Registry redirects are not accepted')
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def registry_bytes(url):
|
|
97
|
+
if not url.startswith(REGISTRY):
|
|
98
|
+
raise ValueError('Unexpected registry authority')
|
|
99
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects())
|
|
100
|
+
with opener.open(url, timeout=30) as response:
|
|
101
|
+
data = response.read(LIMIT + 1)
|
|
102
|
+
if len(data) > LIMIT:
|
|
103
|
+
raise ValueError('Registry response is too large')
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def verify_registry(receipt, fetch=registry_bytes):
|
|
108
|
+
version = receipt['package']['version']
|
|
109
|
+
metadata = json.loads(fetch(REGISTRY + '@hasna%2Frecordings/' + version))
|
|
110
|
+
if (metadata.get('name') != PACKAGE or metadata.get('version') != version
|
|
111
|
+
or metadata.get('dist', {}).get('tarball') != receipt['archive']['url']
|
|
112
|
+
or metadata.get('dist', {}).get('integrity') != receipt['archive']['integrity']):
|
|
113
|
+
raise ValueError('Registry metadata differs from the reviewed archive')
|
|
114
|
+
remote = fetch(receipt['archive']['url'])
|
|
115
|
+
if len(remote) != receipt['archive']['bytes'] or digest(remote) != receipt['archive']['sha256']:
|
|
116
|
+
raise ValueError('Registry archive differs from the reviewed bytes')
|
|
117
|
+
receipt['distribution'] = {'status': 'published', 'registry': REGISTRY,
|
|
118
|
+
'verifiedAt': datetime.now(timezone.utc).isoformat()}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def verify_source(receipt, repository):
|
|
122
|
+
revision = receipt['source']['revision']
|
|
123
|
+
prefix = 'apps/recordings/' + CORE
|
|
124
|
+
result = subprocess.run(['git', '-c', 'tar.umask=0022', 'archive', '--format=tar', revision, '--', prefix, 'apps/recordings/package.json'],
|
|
125
|
+
cwd=repository, check=True, capture_output=True, timeout=30)
|
|
126
|
+
with tarfile.open(fileobj=io.BytesIO(result.stdout), mode='r:') as archive:
|
|
127
|
+
package = archive.extractfile('apps/recordings/package.json')
|
|
128
|
+
metadata = json.load(package) if package else {}
|
|
129
|
+
if metadata.get('name') != PACKAGE or metadata.get('version') != receipt['package']['version']:
|
|
130
|
+
raise ValueError('Package version differs from the public source revision')
|
|
131
|
+
for expected in receipt['native']['files']:
|
|
132
|
+
try:
|
|
133
|
+
member = archive.getmember(prefix + expected['path'])
|
|
134
|
+
handle = archive.extractfile(member)
|
|
135
|
+
data = handle.read() if handle else b''
|
|
136
|
+
except KeyError as error:
|
|
137
|
+
raise ValueError('Native archive member is absent from the source revision') from error
|
|
138
|
+
if (not member.isfile() or len(data) != expected['bytes'] or digest(data) != expected['sha256']
|
|
139
|
+
or member.mode & 0o777 != expected['mode']):
|
|
140
|
+
raise ValueError('Native archive bytes differ from the public source revision')
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main():
|
|
144
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
145
|
+
parser.add_argument('archive', type=pathlib.Path)
|
|
146
|
+
parser.add_argument('--source-revision', required=True)
|
|
147
|
+
parser.add_argument('--repository', type=pathlib.Path, default=pathlib.Path(__file__).resolve().parents[3])
|
|
148
|
+
parser.add_argument('--output', required=True, type=pathlib.Path)
|
|
149
|
+
parser.add_argument('--verify-registry', action='store_true', help='Compare public registry bytes; does not publish')
|
|
150
|
+
args = parser.parse_args()
|
|
151
|
+
if args.archive.is_symlink() or not args.archive.is_file() or args.archive.stat().st_size > LIMIT:
|
|
152
|
+
raise ValueError('Expected a regular bounded npm archive')
|
|
153
|
+
blob = args.archive.read_bytes()
|
|
154
|
+
receipt = create_receipt(blob, args.source_revision)
|
|
155
|
+
verify_source(receipt, args.repository)
|
|
156
|
+
if args.verify_registry:
|
|
157
|
+
verify_registry(receipt)
|
|
158
|
+
# Refuse replacement: a new verification writes a separate reviewable receipt.
|
|
159
|
+
with args.output.open('x') as output:
|
|
160
|
+
json.dump(receipt, output, indent=2)
|
|
161
|
+
output.write('\n')
|
|
162
|
+
print(json.dumps({'status': receipt['distribution']['status'], 'package': receipt['package'],
|
|
163
|
+
'archiveSHA256': receipt['archive']['sha256'], 'nativeTreeSHA256': receipt['native']['treeSHA256']}))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == '__main__':
|
|
167
|
+
try:
|
|
168
|
+
main()
|
|
169
|
+
except Exception:
|
|
170
|
+
# Exception messages can contain local paths or registry/credential diagnostics.
|
|
171
|
+
raise SystemExit('Native core receipt verification failed; no published receipt was produced.')
|
|
@@ -16,6 +16,7 @@ export type NativeMetadata = {
|
|
|
16
16
|
export type NativeHandle = object;
|
|
17
17
|
|
|
18
18
|
export type NativeFsGuard = {
|
|
19
|
+
duplicateDirectoryDescriptor(descriptor: number): NativeHandle;
|
|
19
20
|
openTrustedHome(path: string, uid: number): NativeHandle;
|
|
20
21
|
openDirAt(parent: NativeHandle, leaf: string): NativeHandle;
|
|
21
22
|
openRegularAt(
|
|
@@ -122,6 +123,7 @@ export function nativeFsGuard(): NativeFsGuard {
|
|
|
122
123
|
}
|
|
123
124
|
const loaded = createRequire(import.meta.url)(path) as Partial<NativeFsGuard>;
|
|
124
125
|
for (const name of [
|
|
126
|
+
"duplicateDirectoryDescriptor",
|
|
125
127
|
"openTrustedHome",
|
|
126
128
|
"openDirAt",
|
|
127
129
|
"openRegularAt",
|
|
@@ -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
|
+
}
|