mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-20 06:28:00 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
// Tests for the gen-accessors TS port. Covers:
|
||||
//
|
||||
// - Parse: 3 regex-failure-mode fixtures from the fork (codex catch)
|
||||
// - Parse: lexical marker isolation + invalid declaration diagnostics
|
||||
// - Cache: same input → same key; different swift version → different key;
|
||||
// different tool rev → different key; file modified → 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';
|
||||
@@ -13,9 +16,11 @@ import {
|
||||
collectSwiftFiles,
|
||||
parseSwift,
|
||||
computeCacheKey,
|
||||
computeAccessorHash,
|
||||
generate,
|
||||
pruneCache,
|
||||
render,
|
||||
AccessorGenerationError,
|
||||
type AccessorSpec,
|
||||
} from './gen-accessors';
|
||||
|
||||
@@ -30,12 +35,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseSwift — fork regex-failure-mode fixtures', () => {
|
||||
test('parses @Observable class with simple @Snapshotable fields', () => {
|
||||
test('parses @Observable class with source-marker comments', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
final class AppState {
|
||||
@Snapshotable var isLoggedIn: Bool = false
|
||||
@Snapshotable var username: String = ""
|
||||
// @Snapshotable
|
||||
var isLoggedIn: Bool = false
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
var notSnapshotable: Int = 0
|
||||
}
|
||||
`;
|
||||
@@ -46,12 +53,100 @@ final class AppState {
|
||||
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:
|
||||
[CartItem<Detail>]
|
||||
[Dictionary<String, [Int]>]
|
||||
= []
|
||||
var unrelated: Int = 0
|
||||
}
|
||||
@@ -60,20 +155,67 @@ class Cart {
|
||||
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('CartItem');
|
||||
expect(specs[0]!.fields[0]!.typeText).toContain('Dictionary');
|
||||
});
|
||||
|
||||
test('handles generic types in property signatures', () => {
|
||||
test('handles JSON-compatible generic types in property signatures', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class Repo {
|
||||
@Snapshotable var pages: Dictionary<String, [Result<Item, Error>]> = [:]
|
||||
@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('Result');
|
||||
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', () => {
|
||||
@@ -114,10 +256,7 @@ class B {
|
||||
expect(specs.map(s => s.className).sort()).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
test('skips fields with computed body braces', () => {
|
||||
// Codex flagged "Properties with computed getters / didSet blocks" as a
|
||||
// failure mode of the fork's regex. We deliberately exclude them here —
|
||||
// computed properties are not snapshot-eligible.
|
||||
test('diagnoses fields with computed body braces', () => {
|
||||
const src = `
|
||||
@Observable
|
||||
class M {
|
||||
@@ -127,9 +266,50 @@ class M {
|
||||
}
|
||||
}
|
||||
`;
|
||||
const specs = parseSwift(src);
|
||||
expect(specs).toHaveLength(1);
|
||||
expect(specs[0]!.fields.map(f => f.name)).toEqual(['snapshotted']);
|
||||
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)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +424,68 @@ describe('computeCacheKey', () => {
|
||||
});
|
||||
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', () => {
|
||||
@@ -283,6 +525,35 @@ class AppState {
|
||||
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);
|
||||
@@ -295,6 +566,62 @@ class AppState {
|
||||
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', () => {
|
||||
@@ -332,14 +659,395 @@ describe('render', () => {
|
||||
fields: [{ name: 'a', typeText: 'Int' }, { name: 'b', typeText: 'String' }],
|
||||
}];
|
||||
const out = render(specs, 'build-1.2.3', 'hash-abc');
|
||||
expect(out).toContain('public enum AppStateAccessor');
|
||||
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('buildId: "build-1.2.3"');
|
||||
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', () => {
|
||||
@@ -355,4 +1063,17 @@ describe('collectSwiftFiles', () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user