// Swift-build invariant tests. Runs against the fixture iOS app at // test/fixtures/ios-qa/FixtureApp/. Requires the Swift toolchain // (Xcode CLI tools or stand-alone Swift). Skipped if swift is not on PATH. // // Two invariants: // // 1. Debug-config build succeeds + the StateServer XCTest unit suite // passes (validates that the Swift production code actually runs, // not just compiles). // // 2. Release-config build excludes DebugBridge symbols. This is the // structural Release-build guard from Package.swift's // `.when(configuration: .debug)`. We verify by: // a. swift build -c release succeeds // b. nm -j against the built binary shows zero `DebugBridge*` // symbols // c. swift build -c release with --vv shows DebugBridge target // gated (no compilation step for DebugBridgeCore/UI) import { describe, test, expect } from 'bun:test'; import { spawnSync } from 'child_process'; import { existsSync, readFileSync } from 'fs'; import { join } from 'path'; const ROOT = join(import.meta.dir, '..'); const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp'); const TEMPLATES_PATH = join(ROOT, 'ios-qa/templates'); // Parity: canonical Obj-C touch templates must match the fixture's working // copy. The fixture is the only place the .m / .h are exercised end-to-end // on a real device, so any divergence means consuming apps would ship a // stale, untested version of the SwiftUI hit-test fix. describe('template ↔ fixture parity', () => { test('DebugBridgeTouch.h.template matches fixture include', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.h.template'), 'utf-8'); const fixture = readFileSync( join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'), 'utf-8', ); expect(tmpl).toBe(fixture); }); test('DebugBridgeTouch.m.template matches fixture .m', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8'); const fixture = readFileSync( join(FIXTURE_PATH, 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'), 'utf-8', ); expect(tmpl).toBe(fixture); }); test('Package.swift.template declares all 3 DebugBridge targets', () => { const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8'); // Each target must be present as a library product AND a target definition. for (const name of ['DebugBridgeCore', 'DebugBridgeUI', 'DebugBridgeTouch']) { expect(tmpl).toContain(`name: "${name}"`); } // DebugBridgeUI must depend on the other two; that's how the consuming // app gets the transitive set with one dependency entry. expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/); }); }); function hasSwift(): boolean { const r = spawnSync('swift', ['--version'], { stdio: 'pipe' }); return r.status === 0; } const swiftAvailable = hasSwift(); const describeIfSwift = swiftAvailable ? describe : describe.skip; describeIfSwift('swift build invariants', () => { // DebugBridgeUI + DebugBridgeTouch are iOS-only (they link UIKit). Plain // `swift build` on macOS host can't resolve UIKit, so we scope these // invariants to DebugBridgeCore (Swift, cross-platform) + its XCTest // target. The iOS-only targets are covered by xcodebuild on the device // path (test/skill-e2e-ios-device.test.ts). test('Debug-config build succeeds (DebugBridgeCore)', () => { const r = spawnSync('swift', ['build', '-c', 'debug', '--target', 'DebugBridgeCore'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 120_000, }); if (r.status !== 0) { console.error('swift build stderr:', r.stderr?.toString().slice(0, 4000)); } expect(r.status).toBe(0); }, 180_000); test('XCTest suite for StateServer passes (validates real Swift impl)', () => { const r = spawnSync('swift', ['test', '--filter', 'DebugBridgeCoreTests'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 180_000, }); const stdout = r.stdout?.toString() ?? ''; const stderr = r.stderr?.toString() ?? ''; const combined = stdout + stderr; if (r.status !== 0) { console.error('swift test failure:', combined.slice(-4000)); } expect(r.status).toBe(0); // --filter scopes the run to DebugBridgeCoreTests; the xctest summary // line is "'Selected tests' passed" rather than "'All tests' passed". expect(combined).toMatch(/'(?:All|Selected) tests' passed/); // Guard against an empty pass-by-no-tests (filter typo / target rename): // we expect at least one StateServer smoke test to actually execute. expect(combined).toContain('StateServerSmokeTests'); }, 240_000); // Codex-flagged: Release-build guard must be STRUCTURAL, not advisory. // The Package.swift's `.when(configuration: .debug)` setting causes Swift // to compile-out the entire DebugBridgeCore target body in Release. Since // every public symbol is gated `#if DEBUG`, the release build emits an // empty module — zero symbols. test('Release-config build excludes DebugBridge symbols', () => { // Step 1: clean + release build (Core only — UI/Touch can't build on macOS) spawnSync('swift', ['package', 'clean'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 60_000 }); const build = spawnSync('swift', ['build', '-c', 'release', '--target', 'DebugBridgeCore'], { cwd: FIXTURE_PATH, stdio: 'pipe', timeout: 180_000, }); if (build.status !== 0) { console.error('release build stderr:', build.stderr?.toString().slice(0, 4000)); } expect(build.status).toBe(0); // Step 2: locate the built object file(s). SwiftPM puts .build artifacts // under .build//release/. const oFiles = spawnSync('find', [ join(FIXTURE_PATH, '.build'), '-path', '*/release/*', '-name', '*.o', '-path', '*DebugBridge*', ], { stdio: 'pipe' }); const files = (oFiles.stdout?.toString() ?? '').trim().split('\n').filter(Boolean); expect(files.length).toBeGreaterThan(0); let foundForbidden = 0; const forbidden = ['StateServer', 'handleRequest', 'sessionAcquire', 'authRotate', 'snapshotGet']; for (const f of files) { const nm = spawnSync('nm', ['-j', f], { stdio: 'pipe' }); const syms = nm.stdout?.toString() ?? ''; for (const tok of forbidden) { if (syms.includes(tok)) { console.error(`Release symbol leak: ${tok} found in ${f}`); foundForbidden++; } } } expect(foundForbidden).toBe(0); }, 300_000); });