mirror of https://github.com/garrytan/gstack.git
187 lines
8.4 KiB
TypeScript
187 lines
8.4 KiB
TypeScript
// 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"\]/);
|
|
});
|
|
});
|
|
|
|
// Static guards for the iOS-Release private-SPI leak. The real failure only
|
|
// manifests in an iOS Release build (TARGET_OS_IOS true, DEBUG undefined),
|
|
// which the macOS `swift build` host cannot produce — DebugBridgeTouch links
|
|
// UIKit. So a host-side nm-scan of Touch always reads clean and proves
|
|
// nothing. These cheap text assertions lock the two gates that keep the
|
|
// private UITouch/UIEvent/IOKit SPIs out of App Store binaries (the exact
|
|
// failure that forced a DebugBridge removal in a downstream app's PR #342).
|
|
describe('iOS-Release private-SPI leak guard', () => {
|
|
test('DebugBridgeTouch.m.template gates the iOS impl behind DEBUG', () => {
|
|
const m = readFileSync(join(TEMPLATES_PATH, 'DebugBridgeTouch.m.template'), 'utf-8');
|
|
// The SPI-bearing block must require BOTH iOS AND DEBUG. A bare
|
|
// `#if TARGET_OS_IOS` compiles the private SPIs into iOS Release.
|
|
expect(m).toContain('#if TARGET_OS_IOS && defined(DEBUG)');
|
|
expect(m).not.toMatch(/#if\s+TARGET_OS_IOS\s*$/m);
|
|
});
|
|
|
|
test('Package.swift.template DEBUG-gates the Obj-C touch target via cSettings', () => {
|
|
const pkg = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
|
|
// swiftSettings do NOT reach .m translation units — the Obj-C target
|
|
// needs its own cSettings DEBUG define as belt-and-suspenders.
|
|
const touchTarget = pkg.match(/\.target\(\s*\n\s*name:\s*"DebugBridgeTouch"[\s\S]*?\n\s{8}\)/);
|
|
expect(touchTarget).not.toBeNull();
|
|
expect(touchTarget![0]).toMatch(/cSettings:\s*\[[\s\S]*?\.define\(\s*"DEBUG",\s*to:\s*"1",\s*\.when\(configuration:\s*\.debug\)\)/);
|
|
});
|
|
|
|
test('tools-version directive is the first line of Package.swift.template', () => {
|
|
const pkg = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
|
|
// Swift 6.0+ rejects a tools-version directive that is not on line 1.
|
|
expect(pkg.split('\n')[0]).toMatch(/^\/\/ swift-tools-version:/);
|
|
});
|
|
});
|
|
|
|
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/<triple>/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);
|
|
});
|