mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-11 23:49:01 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
import { afterEach, describe, expect, test } from 'bun:test';
|
|
import { createHash } from 'crypto';
|
|
import { spawnSync } from 'child_process';
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join, relative } from 'path';
|
|
|
|
const ROOT = join(import.meta.dir, '..');
|
|
const SAFE_TEMPLATE_MAP = [
|
|
['Package.swift.template', 'Package.swift'],
|
|
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
|
|
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
|
|
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
|
|
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
|
|
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
|
|
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
|
|
] as const;
|
|
|
|
const workDirs: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const dir of workDirs.splice(0)) {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
|
|
const root = join(workDir, 'fake gstack install');
|
|
const binDir = join(root, 'bin');
|
|
const scriptsDir = join(root, 'ios-qa', 'scripts');
|
|
const templatesDir = join(root, 'ios-qa', 'templates');
|
|
mkdirSync(binDir, { recursive: true });
|
|
mkdirSync(scriptsDir, { recursive: true });
|
|
mkdirSync(templatesDir, { recursive: true });
|
|
|
|
const launcher = join(binDir, 'gstack-ios-qa-regen');
|
|
copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
|
|
chmodSync(launcher, 0o755);
|
|
copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
|
|
for (const [template] of SAFE_TEMPLATE_MAP) {
|
|
copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
|
|
}
|
|
|
|
// These files deliberately exist in the discovered template directory. If
|
|
// the launcher ever regresses to wildcard copying, their sentinel content
|
|
// will escape into the app and fail the absence assertions below.
|
|
writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
|
|
writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
|
|
writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
|
|
return { root, launcher };
|
|
}
|
|
|
|
function treeHash(...roots: string[]): string {
|
|
const hash = createHash('sha256');
|
|
for (const root of roots) {
|
|
const visit = (dir: string): void => {
|
|
for (const name of readdirSync(dir).sort()) {
|
|
const path = join(dir, name);
|
|
const stat = statSync(path);
|
|
if (stat.isDirectory()) {
|
|
visit(path);
|
|
} else {
|
|
hash.update(relative(root, path));
|
|
hash.update('\0');
|
|
hash.update(readFileSync(path));
|
|
hash.update('\0');
|
|
}
|
|
}
|
|
};
|
|
visit(root);
|
|
}
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function allFileContents(root: string): string {
|
|
let contents = '';
|
|
const visit = (dir: string): void => {
|
|
for (const name of readdirSync(dir)) {
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) visit(path);
|
|
else contents += readFileSync(path, 'utf8');
|
|
}
|
|
};
|
|
visit(root);
|
|
return contents;
|
|
}
|
|
|
|
describe('gstack-ios-qa-regen', () => {
|
|
test('repository launcher is executable', () => {
|
|
expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
|
|
});
|
|
|
|
test('requires the documented app-source and bridge-dir contract', () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
|
workDirs.push(workDir);
|
|
const { launcher } = copyIntoFakeInstall(workDir);
|
|
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8', timeout: 30_000 });
|
|
|
|
expect(result.status).toBe(2);
|
|
expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
|
|
});
|
|
|
|
test('leaves no completion marker when accessor generation fails', () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
|
workDirs.push(workDir);
|
|
const { launcher } = copyIntoFakeInstall(workDir);
|
|
const appSource = join(workDir, 'app-source');
|
|
const bridgeDir = join(workDir, 'bridge');
|
|
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
|
const fakeBin = join(workDir, 'fake-bin');
|
|
mkdirSync(generatedDir, { recursive: true });
|
|
mkdirSync(fakeBin, { recursive: true });
|
|
writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
|
|
writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
|
|
const fakeBun = join(fakeBin, 'bun');
|
|
writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
|
|
chmodSync(fakeBun, 0o755);
|
|
|
|
const result = spawnSync('bash', [
|
|
launcher,
|
|
'--app-source', appSource,
|
|
'--bridge-dir', bridgeDir,
|
|
], {
|
|
encoding: 'utf8',
|
|
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
|
|
timeout: 30_000,
|
|
});
|
|
|
|
expect(result.status).toBe(17);
|
|
expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
|
|
});
|
|
|
|
test('regenerates the allowlisted package and accessors idempotently', () => {
|
|
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
|
workDirs.push(workDir);
|
|
const { root, launcher } = copyIntoFakeInstall(workDir);
|
|
const appSource = join(workDir, 'app source');
|
|
const bridgeDir = join(appSource, 'DebugBridge');
|
|
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
|
const cacheRoot = join(workDir, 'isolated cache');
|
|
mkdirSync(appSource, { recursive: true });
|
|
writeFileSync(join(appSource, 'AppState.swift'), `
|
|
@Observable
|
|
final class AppState {
|
|
// @Snapshotable
|
|
var counter: Int = 0
|
|
}
|
|
`);
|
|
|
|
// Seed the flat legacy layout that old ios-sync versions produced. A
|
|
// correct regeneration must remove these known generated artifacts rather
|
|
// than letting Xcode compile a second, stale harness implementation.
|
|
mkdirSync(generatedDir, { recursive: true });
|
|
for (const obsolete of [
|
|
join(bridgeDir, 'DebugBridgeWiring.swift'),
|
|
join(bridgeDir, 'StateAccessor.swift'),
|
|
join(generatedDir, 'Package.swift'),
|
|
join(generatedDir, 'StateServer.swift'),
|
|
join(generatedDir, 'DebugBridgeManager.swift'),
|
|
join(generatedDir, 'Bridges.swift'),
|
|
join(generatedDir, 'DebugOverlay.swift'),
|
|
join(generatedDir, 'DebugBridgeTouch.m'),
|
|
join(generatedDir, 'DebugBridgeTouch.h'),
|
|
join(generatedDir, 'DebugBridgeWiring.swift'),
|
|
]) {
|
|
mkdirSync(join(obsolete, '..'), { recursive: true });
|
|
writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
|
|
}
|
|
|
|
const env = {
|
|
...process.env,
|
|
GSTACK_IOS_CACHE_ROOT: cacheRoot,
|
|
SWIFT_VERSION: '6.3.3',
|
|
GEN_ACCESSORS_REV: 'regen-test',
|
|
};
|
|
const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
|
|
const first = spawnSync('bash', args, { encoding: 'utf8', env, timeout: 30_000 });
|
|
expect(first.status).toBe(0);
|
|
expect(first.stderr).toBe('');
|
|
|
|
// Every installed package file must be byte-identical to its explicit
|
|
// source template: this is the durable template/output parity contract.
|
|
for (const [template, destination] of SAFE_TEMPLATE_MAP) {
|
|
expect(readFileSync(join(bridgeDir, destination))).toEqual(
|
|
readFileSync(join(root, 'ios-qa', 'templates', template)),
|
|
);
|
|
}
|
|
|
|
const accessorPath = join(generatedDir, 'StateAccessor.swift');
|
|
const accessor = readFileSync(accessorPath, 'utf8');
|
|
expect(accessor).toContain('import DebugBridgeCore');
|
|
expect(accessor).toContain('enum AppStateAccessor');
|
|
expect(accessor).not.toContain('public enum AppStateAccessor');
|
|
expect(accessor).not.toContain('import DebugBridge\n');
|
|
expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
|
|
expect(accessor).toContain('atomicRestore: { keys, apply in');
|
|
expect(accessor).toContain('state.counter = restored0');
|
|
expect(accessor).toContain('state.counter = typed');
|
|
expect(accessor).toContain('return true');
|
|
expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
|
|
expect(accessor).not.toContain('write: { _ in false }');
|
|
expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
|
|
readFileSync(join(root, 'VERSION'), 'utf8'),
|
|
);
|
|
|
|
expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
|
|
expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
|
|
for (const obsoleteName of [
|
|
'Package.swift',
|
|
'StateServer.swift',
|
|
'DebugBridgeManager.swift',
|
|
'Bridges.swift',
|
|
'DebugOverlay.swift',
|
|
'DebugBridgeTouch.m',
|
|
'DebugBridgeTouch.h',
|
|
'DebugBridgeWiring.swift',
|
|
]) {
|
|
expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
|
|
}
|
|
const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
|
|
expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
|
|
expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
|
|
expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
|
|
|
|
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8', timeout: 30_000 }).status === 0;
|
|
if (swiftAvailable) {
|
|
const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
|
|
encoding: 'utf8',
|
|
timeout: 30_000,
|
|
});
|
|
expect(dump.status).toBe(0);
|
|
const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
|
|
expect(manifest.targets.map(target => target.name).sort()).toEqual([
|
|
'DebugBridgeCore',
|
|
'DebugBridgeTouch',
|
|
'DebugBridgeUI',
|
|
]);
|
|
}
|
|
|
|
const firstHash = treeHash(bridgeDir, generatedDir);
|
|
const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
|
|
const second = spawnSync('bash', args, { encoding: 'utf8', env, timeout: 30_000 });
|
|
expect(second.status).toBe(0);
|
|
expect(second.stderr).toBe('');
|
|
expect(second.stdout).toContain('gen-accessors: cache hit');
|
|
expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
|
|
expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
|
|
});
|
|
});
|