Files
gstack/ios-qa/scripts/gen-accessors.test.ts
T

1080 lines
37 KiB
TypeScript

// Tests for the gen-accessors TS port. Covers:
//
// - Parse: lexical marker isolation + invalid declaration diagnostics
// - Cache: same input → same key; different swift version → different key;
// different tool rev/build provenance → different key
// - Schema: stable hash depends only on ordered accessor signatures
// - Optional: NSNull read/write/restore behavior and Swift type checking
// - Prune: >30d entries removed, recent kept
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync, utimesSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
collectSwiftFiles,
parseSwift,
computeCacheKey,
computeAccessorHash,
generate,
pruneCache,
render,
AccessorGenerationError,
type AccessorSpec,
} from './gen-accessors';
let workDir: string;
beforeEach(() => {
workDir = mkdtempSync(join(tmpdir(), 'gen-accessors-test-'));
});
afterEach(() => {
rmSync(workDir, { recursive: true, force: true });
});
describe('parseSwift — fork regex-failure-mode fixtures', () => {
test('parses @Observable class with source-marker comments', () => {
const src = `
@Observable
final class AppState {
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
var notSnapshotable: Int = 0
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.className).toBe('AppState');
expect(specs[0]!.fields.map(f => f.name)).toEqual(['isLoggedIn', 'username']);
expect(specs[0]!.fields.find(f => f.name === 'isLoggedIn')!.typeText).toBe('Bool');
});
test('retains legacy @Snapshotable attribute parsing', () => {
const specs = parseSwift(`
@Observable
final class LegacyState {
@Snapshotable var counter: Int = 0
}
`);
expect(specs).toEqual([{
className: 'LegacyState',
fields: [{ name: 'counter', typeText: 'Int' }],
}]);
});
test('does not treat documentation prose as a source marker', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/// Do not expose this field through @Snapshotable.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('does not let a trailing marker comment bleed into the next field', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
var oldValue: Int = 0 // @Snapshotable
var nextValue: Int = 1
}
`);
expect(specs).toHaveLength(0);
});
test('ignores exact-looking markers inside nested block comments', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
/*
/* // @Snapshotable */
// @Snapshotable
var leaked: String = "secret"
*/
// @Snapshotable
var visible: Int = 1
}
`);
expect(specs).toEqual([{
className: 'PrivateState',
fields: [{ name: 'visible', typeText: 'Int' }],
}]);
});
test('ignores declarations and markers inside multiline and raw strings', () => {
const specs = parseSwift(`
let ordinary = """
@Observable class FakeA {
// @Snapshotable
var leaked: Int = 0
}
"""
let raw = #"""
@Observable class FakeB { @Snapshotable var leaked: String = "" }
"""#
@Observable
final class RealState {
// @Snapshotable
var safe: Bool = true
}
`);
expect(specs).toEqual([{
className: 'RealState',
fields: [{ name: 'safe', typeText: 'Bool' }],
}]);
});
test('ignores marker words in standalone trailing prose', () => {
const specs = parseSwift(`
@Observable
final class PrivateState {
// @Snapshotable fields are intentionally disabled here.
var token: String = "secret"
}
`);
expect(specs).toHaveLength(0);
});
test('handles @Snapshotable on multi-line type signatures', () => {
const src = `
@Observable
class Cart {
@Snapshotable var items:
[Dictionary<String, [Int]>]
= []
var unrelated: Int = 0
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields).toHaveLength(1);
expect(specs[0]!.fields[0]!.name).toBe('items');
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
});
test('handles JSON-compatible generic types in property signatures', () => {
const src = `
@Observable
class Repo {
@Snapshotable var pages: Dictionary<String, [Optional<Int>]> = [:]
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(1);
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
expect(specs[0]!.fields[0]!.typeText).toContain('Optional');
});
test('accepts qualified JSON-native scalar and collection spellings', () => {
const specs = parseSwift(`
@Observable
class Metrics {
// @Snapshotable
var ratio: CoreGraphics.CGFloat = 0
// @Snapshotable
var counts: Swift.Array<Swift.Int> = []
}
`);
expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([
'CoreGraphics.CGFloat',
'Swift.Array<Swift.Int>',
]);
});
test('rejects custom values that Foundation JSON cannot serialize', () => {
expect(() => parseSwift(`
@Observable
class Repo {
// @Snapshotable
var pages: Dictionary<String, [Result<Item, Error>]> = [:]
}
`)).toThrow("unsupported snapshot type 'Result<Item,Error>'");
});
test('rejects nested observable types instead of emitting an unqualified reference', () => {
expect(() => parseSwift(`
enum Namespace {
@Observable
class State {
// @Snapshotable
var count: Int = 0
}
}
`)).toThrow('nested @Observable types are not supported');
});
test('ignores nested observable types that expose no snapshot fields', () => {
expect(parseSwift(`
enum Namespace {
@Observable
class State { var transient: Int = 0 }
}
`)).toEqual([]);
});
test('ignores fields without @Snapshotable marker', () => {
const src = `
@Observable
class M {
var plain: Int = 0
@State var stateBacked: String = ""
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(0);
});
test('ignores non-@Observable classes', () => {
const src = `
class Plain {
@Snapshotable var should: Int = 0
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(0);
});
test('handles multiple @Observable classes in one file', () => {
const src = `
@Observable
class A {
@Snapshotable var a: Int = 0
}
@Observable
class B {
@Snapshotable var b: String = ""
}
`;
const specs = parseSwift(src);
expect(specs).toHaveLength(2);
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
});
test('diagnoses fields with computed body braces', () => {
const src = `
@Observable
class M {
@Snapshotable var snapshotted: Int = 0
@Snapshotable var computed: Int {
get { 42 }
}
}
`;
expect(() => parseSwift(src)).toThrow("field 'computed' must be stored and writable");
});
test.each([
['let', '// @Snapshotable\n let immutable: Int = 1', 'must be declared var, not let'],
['private', '// @Snapshotable\n private var secret: String = ""', 'cannot be private'],
['fileprivate', '// @Snapshotable\n fileprivate var secret: String = ""', 'cannot be private'],
['private(set)', '// @Snapshotable\n public private(set) var count: Int = 0', 'cannot be private'],
['fileprivate(set)', '// @Snapshotable\n fileprivate(set) var count: Int = 0', 'cannot be private'],
['inferred type', '// @Snapshotable\n var inferred = 42', 'requires an explicit type annotation'],
['multiple bindings', '// @Snapshotable\n var a: Int = 1, b: Int = 2', 'exactly one binding'],
['static', '// @Snapshotable\n static var shared: Int = 0', 'must be an instance property'],
['nested Optional', '// @Snapshotable\n var nested: String?? = nil', 'cannot use a nested Optional'],
['nested Optional in collection', '// @Snapshotable\n var nestedItems: [String??] = []', 'cannot use a nested Optional'],
['implicitly unwrapped Optional', '// @Snapshotable\n var legacy: String! = nil', 'cannot use an implicitly unwrapped Optional'],
['custom type', '// @Snapshotable\n var custom: CustomValue = .init()', 'uses unsupported snapshot type'],
])('diagnoses invalid %s declarations', (_label, declaration, expected) => {
expect(() => parseSwift(`
@Observable
final class InvalidState {
${declaration}
}
`)).toThrow(expected);
});
test('aggregates invalid declaration diagnostics with class and line context', () => {
try {
parseSwift(`
@Observable
final class InvalidState {
// @Snapshotable
let first: Int = 1
// @Snapshotable
private var second: String = ""
}
`);
throw new Error('expected parseSwift to fail');
} catch (error) {
expect(error).toBeInstanceOf(AccessorGenerationError);
const generationError = error as AccessorGenerationError;
expect(generationError.diagnostics).toHaveLength(2);
expect(generationError.message).toContain('InvalidState (line 4)');
expect(generationError.message).toContain('InvalidState (line 6)');
}
});
});
describe('computeCacheKey', () => {
test('same source + same versioning = same key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const k1 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc123',
platformTriple: 'darwin-arm64',
});
const k2 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc123',
platformTriple: 'darwin-arm64',
});
expect(k1).toBe(k2);
});
test('source modification changes the key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const k1 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc123',
platformTriple: 'darwin-arm64',
});
writeFileSync(f, '@Observable class A { @Snapshotable var x: Int = 0 }');
const k2 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc123',
platformTriple: 'darwin-arm64',
});
expect(k1).not.toBe(k2);
});
test('swift version change invalidates the key (codex catch)', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const k1 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '5.9.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
});
const k2 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
});
expect(k1).not.toBe(k2);
});
test('generator git rev change invalidates the key (codex catch)', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const k1 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc123',
platformTriple: 'darwin-arm64',
});
const k2 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'def456',
platformTriple: 'darwin-arm64',
});
expect(k1).not.toBe(k2);
});
test('platform triple change invalidates the key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const k1 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
});
const k2 = computeCacheKey({
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-x86_64',
});
expect(k1).not.toBe(k2);
});
test('adding/removing files invalidates the key', () => {
const f1 = join(workDir, 'a.swift');
const f2 = join(workDir, 'b.swift');
writeFileSync(f1, '@Observable class A {}');
writeFileSync(f2, '@Observable class B {}');
const k1 = computeCacheKey({
swiftFiles: [f1],
swiftVersion: '6.0.0',
toolGitRev: 'a',
platformTriple: 'd-arm64',
});
const k2 = computeCacheKey({
swiftFiles: [f1, f2],
swiftVersion: '6.0.0',
toolGitRev: 'a',
platformTriple: 'd-arm64',
});
expect(k1).not.toBe(k2);
});
test('app build provenance invalidates the cache key', () => {
const f = join(workDir, 'a.swift');
writeFileSync(f, '@Observable class A {}');
const shared = {
swiftFiles: [f],
swiftVersion: '6.0.0',
toolGitRev: 'abc',
platformTriple: 'darwin-arm64',
};
expect(computeCacheKey({ ...shared, buildId: '100' })).not.toBe(
computeCacheKey({ ...shared, buildId: '101' }),
);
});
test('equivalent source content does not depend on its absolute checkout path', () => {
const firstDir = join(workDir, 'checkout-a');
const secondDir = join(workDir, 'checkout-b');
mkdirSync(firstDir);
mkdirSync(secondDir);
const first = join(firstDir, 'State.swift');
const second = join(secondDir, 'State.swift');
writeFileSync(first, '@Observable class A {}');
writeFileSync(second, '@Observable class A {}');
const versioning = { swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
expect(computeCacheKey({ swiftFiles: [first], ...versioning })).toBe(
computeCacheKey({ swiftFiles: [second], ...versioning }),
);
});
});
describe('computeAccessorHash', () => {
const base: AccessorSpec[] = [{
className: 'AppState',
fields: [
{ name: 'count', typeText: 'Int' },
{ name: 'nickname', typeText: 'String?' },
],
}];
test('is deterministic for the same ordered accessor signatures', () => {
expect(computeAccessorHash(base)).toBe(computeAccessorHash(structuredClone(base)));
});
test('changes when field order, name, or type changes', () => {
const reordered: AccessorSpec[] = [{
className: 'AppState',
fields: [...base[0]!.fields].reverse(),
}];
const renamed: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'total', typeText: 'Int' }, base[0]!.fields[1]!],
}];
const retyped: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'count', typeText: 'Int64' }, base[0]!.fields[1]!],
}];
const hash = computeAccessorHash(base);
expect(computeAccessorHash(reordered)).not.toBe(hash);
expect(computeAccessorHash(renamed)).not.toBe(hash);
expect(computeAccessorHash(retyped)).not.toBe(hash);
});
});
describe('generate', () => {
test('first run writes StateAccessor.swift and populates cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), `
@Observable
class AppState {
@Snapshotable var x: Int = 0
}
`);
const cacheRoot = join(workDir, 'cache');
const r = generate({
inputDir,
cacheRoot,
swiftVersion: '6.0.0',
toolGitRev: 'test',
platformTriple: 'darwin-arm64',
});
expect(r.cacheHit).toBe(false);
expect(r.specs).toHaveLength(1);
expect(r.specs[0]!.className).toBe('AppState');
expect(existsSync(r.outputPath)).toBe(true);
expect(existsSync(join(cacheRoot, r.cacheKey, 'StateAccessor.swift'))).toBe(true);
});
test('second run with same inputs hits the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const r1 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
const r2 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
expect(r1.cacheHit).toBe(false);
expect(r2.cacheHit).toBe(true);
expect(r1.cacheKey).toBe(r2.cacheKey);
});
test('custom nested output subtree cannot poison the second-run cache key', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(inputDir, 'generated', 'accessors');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const r1 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
writeFileSync(join(outputDir, 'Ignored.swift'), '@Observable class Poison { @Snapshotable var bad: Int = 1 }');
const r2 = generate({
inputDir,
outputDir,
cacheRoot,
swiftVersion: '6',
toolGitRev: 't',
platformTriple: 'p',
});
expect(r2.cacheHit).toBe(true);
expect(r2.cacheKey).toBe(r1.cacheKey);
});
test('modifying source invalidates the cache', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
const file = join(inputDir, 'state.swift');
writeFileSync(file, '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const r1 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
writeFileSync(file, '@Observable class A { @Snapshotable var y: String = "" }');
const r2 = generate({ inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' });
expect(r1.cacheKey).not.toBe(r2.cacheKey);
expect(r2.cacheHit).toBe(false);
});
test('unmarked source churn changes cache identity but not schema identity', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
const state = join(inputDir, 'state.swift');
const unrelated = join(inputDir, 'unrelated.swift');
writeFileSync(state, '@Observable class A { // @Snapshotable\n var x: Int = 0\n }');
writeFileSync(unrelated, 'struct Unrelated { let a = 1 }');
const cacheRoot = join(workDir, 'cache');
const options = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate(options);
writeFileSync(unrelated, 'struct Unrelated { let a = 2 }');
const second = generate(options);
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.accessorHash).toBe(first.accessorHash);
});
test('buildId changes cannot reuse generated fallback provenance', () => {
const inputDir = join(workDir, 'src');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), '@Observable class A { @Snapshotable var x: Int = 0 }');
const cacheRoot = join(workDir, 'cache');
const shared = { inputDir, cacheRoot, swiftVersion: '6', toolGitRev: 't', platformTriple: 'p' };
const first = generate({ ...shared, buildId: 'build-100' });
const second = generate({ ...shared, buildId: 'build-101' });
expect(second.cacheKey).not.toBe(first.cacheKey);
expect(second.cacheHit).toBe(false);
expect(readFileSync(second.outputPath, 'utf8')).toContain('?? "build-101"');
});
test('CLI exits 4 with actionable diagnostics and no generated output', () => {
const inputDir = join(workDir, 'src');
const outputDir = join(workDir, 'generated');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'state.swift'), `
@Observable final class InvalidState {
// @Snapshotable
private let secret = "nope"
}
`);
const result = spawnSync('bun', [
join(import.meta.dir, 'gen-accessors.ts'),
'--input', inputDir,
'--output', outputDir,
], {
encoding: 'utf8',
env: { ...process.env, GSTACK_IOS_CACHE_ROOT: join(workDir, 'cache') },
});
expect(result.status).toBe(4);
// `let` is diagnosed first; either way the class/property is named and
// generation never emits an accessor that will fail in xcodebuild.
expect(result.stderr).toContain('InvalidState');
expect(result.stderr).toContain("field 'secret' must be declared var, not let");
expect(result.stderr).not.toContain('AccessorGenerationError:');
expect(existsSync(join(outputDir, 'StateAccessor.swift'))).toBe(false);
});
});
describe('pruneCache', () => {
test('removes entries older than 30d, keeps recent', () => {
const cacheRoot = join(workDir, 'cache');
mkdirSync(cacheRoot, { recursive: true });
const old = join(cacheRoot, 'old-key');
const fresh = join(cacheRoot, 'fresh-key');
mkdirSync(old);
mkdirSync(fresh);
writeFileSync(join(old, 'StateAccessor.swift'), '// old');
writeFileSync(join(fresh, 'StateAccessor.swift'), '// fresh');
// Backdate the old dir by 60 days.
const sixtyDaysAgo = (Date.now() - 60 * 24 * 60 * 60 * 1000) / 1000;
utimesSync(old, sixtyDaysAgo, sixtyDaysAgo);
const { pruned } = pruneCache(cacheRoot, 30);
expect(pruned).toHaveLength(1);
expect(pruned[0]).toBe(old);
expect(existsSync(old)).toBe(false);
expect(existsSync(fresh)).toBe(true);
});
test('no-op on empty cache dir', () => {
const { pruned } = pruneCache(join(workDir, 'nope'), 30);
expect(pruned).toHaveLength(0);
});
});
describe('render', () => {
test('emits valid-looking Swift for one class with two fields', () => {
const specs: AccessorSpec[] = [{
className: 'AppState',
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
}];
const out = render(specs, 'build-1.2.3', 'hash-abc');
expect(out).toContain('enum AppStateAccessor');
expect(out).not.toContain('public enum AppStateAccessor');
expect(out).toContain('static func register(_ state: AppState)');
expect(out).not.toContain('public static func register(_ state: AppState)');
expect(out).toContain('key: "a"');
expect(out).toContain('key: "b"');
expect(out).toContain('return .missingKey("a")');
expect(out).toContain('return .typeMismatch("b")');
expect(out).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
expect(out).toContain('guard let restored1 = Self.decodeSnapshotValue(raw1, as: String.self)');
expect(out).toContain('atomicRestore: { keys, apply in');
expect(out).toContain('if apply {');
expect(out).toContain('state.a = restored0');
expect(out).toContain('state.b = restored1');
expect(out.indexOf('state.a = restored0')).toBeGreaterThan(out.indexOf('guard let restored1'));
expect(out).toContain('guard let typed = Self.decodeSnapshotValue(value, as: Int.self) else { return false }');
expect(out).toContain('state.a = typed');
expect(out).toContain('return true');
expect(out).not.toContain('atomicRestore: { _ in .ok }');
expect(out).not.toContain('write: { _ in false }');
expect(out).toContain('CFBundleShortVersionString');
expect(out).toContain('CFBundleVersion');
expect(out).toContain('return shortVersion ?? bundleVersion ?? "build-1.2.3"');
expect(out).toContain('accessorHash: "hash-abc"');
expect(out).toContain('import DebugBridgeCore');
expect(out).not.toContain('import DebugBridge\n');
expect(out).toContain('#if DEBUG');
expect(out).toContain('#endif');
});
test('emits explicit NSNull round-trip handling for Optional fields', () => {
const out = render([{
className: 'AppState',
fields: [
{ name: 'nickname', typeText: 'String?' },
{ name: 'selection', typeText: 'Optional<Int>' },
],
}], 'build', 'schema');
expect(out).toContain('let restored0: String?');
expect(out).toContain('if raw0 is NSNull');
expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw0, as: String.self)');
expect(out).toContain('let restored1: Optional<Int>');
expect(out).toContain('else if let typed = Self.decodeSnapshotValue(raw1, as: Int.self)');
expect(out).toContain('guard let value = state.nickname else { return NSNull() }');
expect(out).toContain('if value is NSNull');
expect(out).toContain('state.nickname = nil');
expect(out).not.toContain('value as? String?');
});
test('rejects duplicate snapshot keys across observable models', () => {
expect(() => render([
{ className: 'FirstState', fields: [{ name: 'count', typeText: 'Int' }] },
{ className: 'SecondState', fields: [{ name: 'count', typeText: 'Int' }] },
], 'build', 'schema')).toThrow("snapshot key 'count' is declared by both FirstState and SecondState");
});
test('typechecks beside an internal @Observable app state using a comment marker', () => {
if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
const coreSource = join(workDir, 'DebugBridgeCore.swift');
const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
writeFileSync(coreSource, `
public typealias JSONDict = [String: Any]
@MainActor
public final class StateServer {
public static let shared = StateServer()
public enum RestoreResult {
case ok
case missingKey(String)
case typeMismatch(String)
}
private init() {}
public func register(
buildId: String,
accessorHash: String,
atomicRestore: @escaping (JSONDict, Bool) -> RestoreResult
) {}
public func registerAccessor(
key: String,
type: String,
read: @escaping () -> Any?,
write: @escaping (Any) -> Bool
) {}
}
`);
const emitModule = spawnSync('swiftc', [
'-emit-module',
'-parse-as-library',
'-module-name', 'DebugBridgeCore',
coreSource,
'-emit-module-path', coreModule,
], { encoding: 'utf8' });
if (emitModule.status !== 0) {
throw new Error(`failed to build DebugBridgeCore test stub:\n${emitModule.stderr}`);
}
const appSource = join(workDir, 'AppState.swift');
writeFileSync(appSource, `import Observation
@Observable
final class AppState {
// @Snapshotable
var counter: Int = 0
}
${render([{
className: 'AppState',
fields: [{ name: 'counter', typeText: 'Int' }],
}], 'build-test', 'hash-test')}`);
const typecheck = spawnSync('swiftc', [
'-typecheck',
'-D', 'DEBUG',
'-I', workDir,
appSource,
], { encoding: 'utf8' });
if (typecheck.status !== 0) {
throw new Error(`generated accessor failed Swift type checking:\n${typecheck.stderr}`);
}
});
test('strict JSON typing and cross-model validate-before-apply restore run correctly', () => {
if (process.platform !== 'darwin') return;
if (spawnSync('swiftc', ['--version'], { encoding: 'utf8' }).status !== 0) return;
const coreSource = join(workDir, 'DebugBridgeCore.swift');
const coreModule = join(workDir, 'DebugBridgeCore.swiftmodule');
const coreLibrary = join(workDir, 'libDebugBridgeCore.dylib');
writeFileSync(coreSource, `
public typealias JSONDict = [String: Any]
@MainActor
public final class StateServer {
public typealias Restore = (JSONDict, Bool) -> RestoreResult
public enum RestoreResult { case ok, missingKey(String), typeMismatch(String) }
public static let shared = StateServer()
public var restores: [Restore] = []
public var reads: [String: () -> Any?] = [:]
public var writes: [String: (Any) -> Bool] = [:]
private init() {}
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping Restore) {
restores.append(atomicRestore)
}
public func registerAccessor(
key: String,
type: String,
read: @escaping () -> Any?,
write: @escaping (Any) -> Bool
) {
reads[key] = read
writes[key] = write
}
public func restoreAll(_ keys: JSONDict) -> RestoreResult {
for restore in restores {
let result = restore(keys, false)
guard case .ok = result else { return result }
}
for restore in restores {
let result = restore(keys, true)
guard case .ok = result else { return result }
}
return .ok
}
}
`);
const emitCore = spawnSync('swiftc', [
'-emit-library', '-emit-module', '-parse-as-library',
'-module-name', 'DebugBridgeCore', coreSource,
'-emit-module-path', coreModule,
'-o', coreLibrary,
], { encoding: 'utf8' });
if (emitCore.status !== 0) throw new Error(`failed to build runtime stub:\n${emitCore.stderr}`);
const appSource = join(workDir, 'OptionalRoundTrip.swift');
writeFileSync(appSource, `
import Foundation
import Observation
import DebugBridgeCore
@Observable
final class AppState {
// @Snapshotable
var nickname: String? = nil
// @Snapshotable
var count: Int = 1
}
@Observable
final class FeatureState {
// @Snapshotable
var enabled: Bool = false
}
${render([
{
className: 'AppState',
fields: [
{ name: 'nickname', typeText: 'String?' },
{ name: 'count', typeText: 'Int' },
],
},
{
className: 'FeatureState',
fields: [{ name: 'enabled', typeText: 'Bool' }],
},
], 'fallback-build', 'schema-hash')}
@main
struct Runner {
@MainActor static func main() {
let state = AppState()
let feature = FeatureState()
AppStateAccessor.register(state)
FeatureStateAccessor.register(feature)
guard StateServer.shared.reads["nickname"]?() is NSNull else { fatalError("nil read") }
guard StateServer.shared.writes["nickname"]?("Ada") == true, state.nickname == "Ada" else {
fatalError("optional write")
}
guard StateServer.shared.writes["nickname"]?(NSNull()) == true, state.nickname == nil else {
fatalError("null write")
}
guard StateServer.shared.writes["count"]?(true) == false, state.count == 1 else {
fatalError("boolean must not coerce to integer")
}
guard StateServer.shared.writes["enabled"]?(1) == false, feature.enabled == false else {
fatalError("integer must not coerce to boolean")
}
let valid = try! JSONSerialization.jsonObject(
with: Data(#"{"nickname":"Grace","count":7,"enabled":true}"#.utf8)
) as! JSONDict
switch StateServer.shared.restoreAll(valid) {
case .ok: break
default: fatalError("valid restore")
}
guard state.nickname == "Grace", state.count == 7, feature.enabled else { fatalError("restore values") }
switch StateServer.shared.restoreAll(["nickname": NSNull(), "count": 8, "enabled": false]) {
case .ok: break
default: fatalError("null restore")
}
guard state.nickname == nil, state.count == 8, !feature.enabled else { fatalError("null restore values") }
state.nickname = "unchanged"
state.count = 9
feature.enabled = false
switch StateServer.shared.restoreAll(["nickname": "would-partially-apply", "count": 10, "enabled": 1]) {
case .typeMismatch("enabled"): break
default: fatalError("expected mismatch")
}
guard state.nickname == "unchanged", state.count == 9, !feature.enabled else { fatalError("cross-model partial mutation") }
}
}
`);
const executable = join(workDir, 'optional-round-trip');
const compile = spawnSync('swiftc', [
'-D', 'DEBUG', '-I', workDir, '-L', workDir, '-lDebugBridgeCore',
'-parse-as-library', appSource, '-o', executable,
], { encoding: 'utf8' });
if (compile.status !== 0) throw new Error(`generated Optional accessor failed compilation:\n${compile.stderr}`);
const run = spawnSync(executable, [], {
encoding: 'utf8',
env: { ...process.env, DYLD_LIBRARY_PATH: workDir },
});
if (run.status !== 0) throw new Error(`generated Optional accessor failed at runtime:\n${run.stderr}`);
});
});
describe('SwiftSyntax generator parity', () => {
test('isolates canonical markers and rejects inaccessible fields', () => {
if (process.platform !== 'darwin') return;
if (spawnSync('swift', ['--version'], { encoding: 'utf8' }).status !== 0) return;
const packageDir = join(import.meta.dir, 'gen-accessors-tool');
const inputDir = join(workDir, 'swift-syntax-input');
const outputDir = join(workDir, 'swift-syntax-output');
const cacheRoot = join(workDir, 'swift-syntax-cache');
mkdirSync(inputDir);
writeFileSync(join(inputDir, 'State.swift'), `
import Observation
@Observable
final class ToolState {
let documentation = """
// @Snapshotable
var stringLeak: String = "no"
"""
/* // @Snapshotable */
var blockLeak: Int = 1
var trailingLeak: Int = 2 // @Snapshotable
// prose about @Snapshotable must not opt in
var proseLeak: Int = 3
// @Snapshotable
var nickname: String? = nil
// @Snapshotable
var names:
[String]
= []
// @Snapshotable
var count: Int = 0
// @Snapshotable
var enabled: Bool = false
}
`);
const env = {
...process.env,
GSTACK_IOS_CACHE_ROOT: cacheRoot,
APP_BUILD_ID: 'syntax-test-build',
GEN_ACCESSORS_REV: 'syntax-test-v5',
};
const run = spawnSync('swift', [
'run', '--package-path', packageDir, 'gen-accessors',
'--input', inputDir, '--output', outputDir,
], { encoding: 'utf8', env, timeout: 180_000 });
if (run.status !== 0) throw new Error(`SwiftSyntax generator failed:\n${run.stderr}`);
const output = readFileSync(join(outputDir, 'StateAccessor.swift'), 'utf8');
expect(output).toContain('key: "nickname"');
expect(output).toContain('key: "names"');
expect(output).toContain('key: "count"');
expect(output).toContain('key: "enabled"');
expect(output).toContain('_GStackDebugBridgeSnapshotJSON.decode(raw0');
expect(output).toContain('atomicRestore: { keys, apply in');
expect(output).toContain('if apply {');
expect(output).not.toMatch(/raw\d+ as\?/);
expect(output).toContain('CFBundleShortVersionString');
expect(output).toContain(`accessorHash: "${computeAccessorHash([{
className: 'ToolState',
fields: [
{ name: 'nickname', typeText: 'String?' },
{ name: 'names', typeText: '[String]' },
{ name: 'count', typeText: 'Int' },
{ name: 'enabled', typeText: 'Bool' },
],
}])}"`);
expect(output).not.toContain('key: "stringLeak"');
expect(output).not.toContain('key: "blockLeak"');
expect(output).not.toContain('key: "trailingLeak"');
expect(output).not.toContain('key: "proseLeak"');
const invalidInput = join(workDir, 'swift-syntax-invalid');
const invalidOutput = join(workDir, 'swift-syntax-invalid-output');
mkdirSync(invalidInput);
writeFileSync(join(invalidInput, 'Invalid.swift'), `
import Observation
@Observable
final class InvalidState {
// @Snapshotable
public private(set) var token: String = "secret"
// @Snapshotable
let immutable: Int = 1
// @Snapshotable
var inferred = 2
// @Snapshotable
var legacy: String! = nil
// @Snapshotable
var custom: Date = .now
}
enum Namespace {
@Observable
final class NestedState {
// @Snapshotable
var nestedValue: Int = 0
}
}
@Observable
final class FirstState {
// @Snapshotable
var shared: String = "first"
}
@Observable
final class DuplicateState {
// @Snapshotable
var shared: String = "duplicate"
}
`);
const invalid = spawnSync('swift', [
'run', '--package-path', packageDir, 'gen-accessors',
'--input', invalidInput, '--output', invalidOutput,
], { encoding: 'utf8', env, timeout: 180_000 });
expect(invalid.status).toBe(4);
expect(invalid.stderr).toContain('InvalidState.token cannot be private');
expect(invalid.stderr).toContain('InvalidState.immutable must be declared var, not let');
expect(invalid.stderr).toContain('InvalidState.inferred requires an explicit type annotation');
expect(invalid.stderr).toContain('InvalidState.legacy cannot use an implicitly unwrapped Optional type');
expect(invalid.stderr).toContain("InvalidState.custom uses unsupported non-JSON snapshot type 'Date'");
expect(invalid.stderr).toContain('nested @Observable class NestedState');
expect(invalid.stderr).toContain("snapshot key 'shared' is declared by both FirstState and DuplicateState");
expect(existsSync(join(invalidOutput, 'StateAccessor.swift'))).toBe(false);
}, 180_000);
});
describe('collectSwiftFiles', () => {
test('walks subdirectories and finds all .swift files sorted', () => {
const a = join(workDir, 'a.swift');
const sub = join(workDir, 'sub');
mkdirSync(sub);
const b = join(sub, 'b.swift');
const c = join(workDir, 'c.txt');
writeFileSync(a, 'a');
writeFileSync(b, 'b');
writeFileSync(c, 'c');
const files = collectSwiftFiles(workDir);
expect(files.sort()).toEqual([a, b].sort());
});
test('excludes the configured output subtree and every StateAccessor.swift', () => {
const source = join(workDir, 'App.swift');
const staleAccessor = join(workDir, 'old', 'StateAccessor.swift');
const outputDir = join(workDir, 'custom-output');
mkdirSync(join(workDir, 'old'));
mkdirSync(outputDir);
writeFileSync(source, 'struct App {}');
writeFileSync(staleAccessor, '// stale generated output');
writeFileSync(join(outputDir, 'Poison.swift'), '// must not be scanned');
expect(collectSwiftFiles(workDir, { outputDir })).toEqual([source]);
});
});