// 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] = [] 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]> = [:] } `; 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 = [] } `); expect(specs[0]!.fields.map((field) => field.typeText)).toEqual([ 'CoreGraphics.CGFloat', 'Swift.Array', ]); }); test('rejects custom values that Foundation JSON cannot serialize', () => { expect(() => parseSwift(` @Observable class Repo { // @Snapshotable var pages: Dictionary]> = [:] } `)).toThrow("unsupported snapshot type 'Result'"); }); 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' }, ], }], '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'); 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]); }); });