mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-19 14:07:22 +02:00
fix(ios-qa): generate app-owned bridge accessors deterministically
This commit is contained in:
+9
-20
@@ -14,12 +14,16 @@ import Foundation
|
||||
public final class DebugBridgeManager {
|
||||
public static let shared = DebugBridgeManager()
|
||||
|
||||
public func start(appState: AppState) {
|
||||
// 1. Register the canonical AppState struct + accessor wiring.
|
||||
// AppStateAccessor.register(_:) is generated by gen-accessors-tool.
|
||||
AppStateAccessor.register(appState)
|
||||
/// Register app-owned generated accessors, then start the server. The
|
||||
/// registration closure is passed in from the consuming app because the
|
||||
/// DebugBridgeCore package cannot import app-target types. On UIKit apps,
|
||||
/// call DebugBridgeUIWiring.installAll() before this method so a warm
|
||||
/// daemon cannot reach uninitialized resolvers during listener startup.
|
||||
public func start<State>(appState: State, register: (State) -> Void) {
|
||||
register(appState)
|
||||
|
||||
// 2. Boot the StateServer.
|
||||
// Boot only after registration so the first snapshot has a real build
|
||||
// id, schema hash, and key set.
|
||||
StateServer.shared.start()
|
||||
|
||||
// 3. The consuming app installs DebugOverlayWindow separately. See
|
||||
@@ -31,19 +35,4 @@ public final class DebugBridgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder. gen-accessors-tool emits the real `AppStateAccessor` enum next
|
||||
// to the app's canonical state struct. Apps that haven't run codegen get a
|
||||
// stub that registers no accessors (snapshot is empty, restore returns
|
||||
// missing-key for every key).
|
||||
@MainActor
|
||||
public enum AppStateAccessor {
|
||||
public static var register: (Any) -> Void = { _ in }
|
||||
}
|
||||
|
||||
// Apps declare their canonical state struct; codegen reads it and emits
|
||||
// AppStateAccessor.register. The app's struct must be `@Observable` and
|
||||
// must hold all snapshot-eligible state in `@Snapshotable`-marked fields.
|
||||
@MainActor
|
||||
public protocol AppState: AnyObject {}
|
||||
|
||||
#endif // DEBUG
|
||||
|
||||
+49
-22
@@ -59,18 +59,20 @@ public final class StateServer {
|
||||
private var writeHandlers: [String: WriteHandler] = [:]
|
||||
private var typeNames: [String: TypeName] = [:]
|
||||
|
||||
// Atomic-restore hook. Codegen wires this to the canonical AppState struct.
|
||||
// Restore replaces the entire struct in one assignment so SwiftUI's Combine
|
||||
// pipeline observes exactly one change notification — true observable
|
||||
// atomicity. @MainActor alone doesn't guarantee that.
|
||||
public typealias AtomicRestoreFn = (JSONDict) -> RestoreResult
|
||||
// Validated-restore hooks. Every generated model registers one two-phase
|
||||
// handler. The server validates all models before it lets any model mutate,
|
||||
// so invalid input can never cause a cross-model partial restore.
|
||||
// Valid restores apply properties on MainActor; observers may receive one
|
||||
// change notification per property because arbitrary @Observable models do
|
||||
// not expose a general single-assignment transaction API.
|
||||
public typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult
|
||||
public enum RestoreResult {
|
||||
case ok
|
||||
case missingKey(String)
|
||||
case typeMismatch(String)
|
||||
case schemaMismatch(expected: String, got: String)
|
||||
}
|
||||
private var atomicRestore: AtomicRestoreFn?
|
||||
private var atomicRestores: [AtomicRestoreFn] = []
|
||||
|
||||
// Snapshot schema hash — written by codegen, stable across builds with
|
||||
// identical accessor signatures.
|
||||
@@ -109,7 +111,7 @@ public final class StateServer {
|
||||
public func register(buildId: String, accessorHash: String, atomicRestore: @escaping AtomicRestoreFn) {
|
||||
self.appBuildId = buildId
|
||||
self.accessorHash = accessorHash
|
||||
self.atomicRestore = atomicRestore
|
||||
self.atomicRestores.append(atomicRestore)
|
||||
}
|
||||
|
||||
public func registerAccessor(key: String, type: String, read: @escaping ReadHandler, write: @escaping WriteHandler) {
|
||||
@@ -284,6 +286,7 @@ public final class StateServer {
|
||||
"version": "1.0.0",
|
||||
"build": appBuildId,
|
||||
"accessor_hash": accessorHash,
|
||||
"bundle_id": Bundle.main.bundleIdentifier ?? "unknown",
|
||||
])
|
||||
return
|
||||
}
|
||||
@@ -476,22 +479,36 @@ public final class StateServer {
|
||||
send(connection: connection, status: 400, body: ["error": "missing_keys"])
|
||||
return
|
||||
}
|
||||
guard let restore = atomicRestore else {
|
||||
guard !atomicRestores.isEmpty else {
|
||||
send(connection: connection, status: 503, body: ["error": "atomic_restore_not_registered"])
|
||||
return
|
||||
}
|
||||
// Validate-then-apply via the codegen-supplied closure. The closure does
|
||||
// a single struct-assignment so SwiftUI sees one change notification.
|
||||
switch restore(keys) {
|
||||
case .ok:
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
// Phase one validates every registered model without assignment.
|
||||
for restore in atomicRestores {
|
||||
switch restore(keys, false) {
|
||||
case .ok:
|
||||
continue
|
||||
case .missingKey(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "missing"])
|
||||
return
|
||||
case .typeMismatch(let k):
|
||||
send(connection: connection, status: 400, body: ["error": "validation_failed", "key": k, "reason": "type-mismatch"])
|
||||
return
|
||||
case .schemaMismatch(let expected, let got):
|
||||
send(connection: connection, status: 409, body: ["error": "schema_mismatch", "expected_hash": expected, "got_hash": got])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Phase two applies only after every model accepted the immutable input.
|
||||
// A valid multi-field restore may notify once per property.
|
||||
for restore in atomicRestores {
|
||||
guard case .ok = restore(keys, true) else {
|
||||
send(connection: connection, status: 500, body: ["error": "restore_apply_failed"])
|
||||
return
|
||||
}
|
||||
}
|
||||
send(connection: connection, status: 200, body: ["ok": true])
|
||||
}
|
||||
|
||||
// MARK: Stubs (real impls live in DebugBridgeManager + UIKit)
|
||||
@@ -522,9 +539,19 @@ public final class StateServer {
|
||||
// MARK: Response
|
||||
|
||||
private func send(connection: NWConnection, status: Int, body: JSONDict) {
|
||||
let json = (try? JSONSerialization.data(withJSONObject: body)) ?? Data("{}".utf8)
|
||||
let responseStatus: Int
|
||||
let json: Data
|
||||
if JSONSerialization.isValidJSONObject(body),
|
||||
let encoded = try? JSONSerialization.data(withJSONObject: body) {
|
||||
responseStatus = status
|
||||
json = encoded
|
||||
} else {
|
||||
logger.error("Refusing to send a non-JSON response body")
|
||||
responseStatus = 500
|
||||
json = Data("{\"error\":\"response_not_json_serializable\"}".utf8)
|
||||
}
|
||||
let statusText: String
|
||||
switch status {
|
||||
switch responseStatus {
|
||||
case 200: statusText = "OK"
|
||||
case 400: statusText = "Bad Request"
|
||||
case 401: statusText = "Unauthorized"
|
||||
@@ -537,7 +564,7 @@ public final class StateServer {
|
||||
case 503: statusText = "Service Unavailable"
|
||||
default: statusText = "Status"
|
||||
}
|
||||
let header = "HTTP/1.1 \(status) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
let header = "HTTP/1.1 \(responseStatus) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(json.count)\r\nConnection: close\r\n\r\n"
|
||||
var packet = Data(header.utf8)
|
||||
packet.append(json)
|
||||
connection.send(content: packet, completion: .contentProcessed { _ in
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
// Canonical app state for the fixture. Every snapshot-eligible field is
|
||||
// marked with the @Snapshotable property wrapper that the codegen tool
|
||||
// detects via attribute scan.
|
||||
//
|
||||
// Note: we DON'T use @Observable here because the macro expansion converts
|
||||
// stored properties into computed ones, which the @Snapshotable wrapper
|
||||
// can't apply to. In production apps that need both observability AND
|
||||
// snapshotting, the right pattern is:
|
||||
// - Use ObservableObject + @Published (older API), or
|
||||
// - Hold all @Snapshotable state in a nested struct + replace it
|
||||
// wholesale on restore so SwiftUI sees a single change notification
|
||||
// (the canonical-state-struct atomicity strategy from the plan).
|
||||
// Canonical observable app state for the fixture. Snapshot eligibility is a
|
||||
// generator-only source marker, not a property wrapper, so it composes with
|
||||
// Observation's @Observable macro.
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
public final class FixtureAppState {
|
||||
@Snapshotable public var isLoggedIn: Bool = false
|
||||
@Snapshotable public var username: String = ""
|
||||
@Snapshotable public var tapCounter: Int = 0
|
||||
@Observable
|
||||
final class FixtureAppState {
|
||||
// @Snapshotable
|
||||
var isLoggedIn: Bool = false
|
||||
// @Snapshotable
|
||||
var username: String = ""
|
||||
// @Snapshotable
|
||||
var tapCounter: Int = 0
|
||||
// @Snapshotable
|
||||
var nickname: String? = nil
|
||||
/// Not snapshotted — ephemeral cache that should never leak via /state/snapshot.
|
||||
public var ephemeralCache: [String: String] = [:]
|
||||
var ephemeralCache: [String: String] = [:]
|
||||
|
||||
public init() {}
|
||||
}
|
||||
|
||||
/// Property wrapper marker for snapshot-eligible state. The actual wrapper
|
||||
/// is a no-op at runtime; codegen-tool detection happens via attribute scan.
|
||||
@propertyWrapper
|
||||
public struct Snapshotable<Value> {
|
||||
public var wrappedValue: Value
|
||||
public init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
|
||||
init() {}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'crypto';
|
||||
import { spawnSync } from 'child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
const SAFE_TEMPLATE_MAP = [
|
||||
['Package.swift.template', 'Package.swift'],
|
||||
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
|
||||
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
|
||||
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
|
||||
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
|
||||
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
|
||||
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
|
||||
] as const;
|
||||
|
||||
const workDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of workDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function copyIntoFakeInstall(workDir: string): { root: string; launcher: string } {
|
||||
const root = join(workDir, 'fake gstack install');
|
||||
const binDir = join(root, 'bin');
|
||||
const scriptsDir = join(root, 'ios-qa', 'scripts');
|
||||
const templatesDir = join(root, 'ios-qa', 'templates');
|
||||
mkdirSync(binDir, { recursive: true });
|
||||
mkdirSync(scriptsDir, { recursive: true });
|
||||
mkdirSync(templatesDir, { recursive: true });
|
||||
|
||||
const launcher = join(binDir, 'gstack-ios-qa-regen');
|
||||
copyFileSync(join(ROOT, 'bin', 'gstack-ios-qa-regen'), launcher);
|
||||
chmodSync(launcher, 0o755);
|
||||
copyFileSync(join(ROOT, 'ios-qa', 'scripts', 'gen-accessors.ts'), join(scriptsDir, 'gen-accessors.ts'));
|
||||
for (const [template] of SAFE_TEMPLATE_MAP) {
|
||||
copyFileSync(join(ROOT, 'ios-qa', 'templates', template), join(templatesDir, template));
|
||||
}
|
||||
|
||||
// These files deliberately exist in the discovered template directory. If
|
||||
// the launcher ever regresses to wildcard copying, their sentinel content
|
||||
// will escape into the app and fail the absence assertions below.
|
||||
writeFileSync(join(templatesDir, 'DebugBridgeWiring.swift.template'), '// FORBIDDEN-WIRING-SENTINEL\n');
|
||||
writeFileSync(join(templatesDir, 'StateAccessor.swift.template'), '// FORBIDDEN-STATE-SENTINEL\n');
|
||||
writeFileSync(join(root, 'VERSION'), '9.8.7.6\n');
|
||||
return { root, launcher };
|
||||
}
|
||||
|
||||
function treeHash(...roots: string[]): string {
|
||||
const hash = createHash('sha256');
|
||||
for (const root of roots) {
|
||||
const visit = (dir: string): void => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const path = join(dir, name);
|
||||
const stat = statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
visit(path);
|
||||
} else {
|
||||
hash.update(relative(root, path));
|
||||
hash.update('\0');
|
||||
hash.update(readFileSync(path));
|
||||
hash.update('\0');
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function allFileContents(root: string): string {
|
||||
let contents = '';
|
||||
const visit = (dir: string): void => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) visit(path);
|
||||
else contents += readFileSync(path, 'utf8');
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return contents;
|
||||
}
|
||||
|
||||
describe('gstack-ios-qa-regen', () => {
|
||||
test('repository launcher is executable', () => {
|
||||
expect(statSync(join(ROOT, 'bin', 'gstack-ios-qa-regen')).mode & 0o111).not.toBe(0);
|
||||
});
|
||||
|
||||
test('requires the documented app-source and bridge-dir contract', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { launcher } = copyIntoFakeInstall(workDir);
|
||||
const result = spawnSync('bash', [launcher, '--app-source', workDir], { encoding: 'utf8' });
|
||||
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stderr).toContain('both --app-source and --bridge-dir are required');
|
||||
});
|
||||
|
||||
test('leaves no completion marker when accessor generation fails', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { launcher } = copyIntoFakeInstall(workDir);
|
||||
const appSource = join(workDir, 'app-source');
|
||||
const bridgeDir = join(workDir, 'bridge');
|
||||
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
||||
const fakeBin = join(workDir, 'fake-bin');
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
writeFileSync(join(appSource, 'AppState.swift'), '@Observable final class AppState {}\n');
|
||||
writeFileSync(join(generatedDir, '.gstack-version'), 'stale-complete-marker\n');
|
||||
const fakeBun = join(fakeBin, 'bun');
|
||||
writeFileSync(fakeBun, '#!/bin/sh\nexit 17\n');
|
||||
chmodSync(fakeBun, 0o755);
|
||||
|
||||
const result = spawnSync('bash', [
|
||||
launcher,
|
||||
'--app-source', appSource,
|
||||
'--bridge-dir', bridgeDir,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ''}` },
|
||||
});
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
expect(existsSync(join(generatedDir, '.gstack-version'))).toBe(false);
|
||||
});
|
||||
|
||||
test('regenerates the allowlisted package and accessors idempotently', () => {
|
||||
const workDir = mkdtempSync(join(tmpdir(), 'ios-qa-regen-'));
|
||||
workDirs.push(workDir);
|
||||
const { root, launcher } = copyIntoFakeInstall(workDir);
|
||||
const appSource = join(workDir, 'app source');
|
||||
const bridgeDir = join(appSource, 'DebugBridge');
|
||||
const generatedDir = join(appSource, 'DebugBridgeGenerated');
|
||||
const cacheRoot = join(workDir, 'isolated cache');
|
||||
mkdirSync(appSource, { recursive: true });
|
||||
writeFileSync(join(appSource, 'AppState.swift'), `
|
||||
@Observable
|
||||
final class AppState {
|
||||
// @Snapshotable
|
||||
var counter: Int = 0
|
||||
}
|
||||
`);
|
||||
|
||||
// Seed the flat legacy layout that old ios-sync versions produced. A
|
||||
// correct regeneration must remove these known generated artifacts rather
|
||||
// than letting Xcode compile a second, stale harness implementation.
|
||||
mkdirSync(generatedDir, { recursive: true });
|
||||
for (const obsolete of [
|
||||
join(bridgeDir, 'DebugBridgeWiring.swift'),
|
||||
join(bridgeDir, 'StateAccessor.swift'),
|
||||
join(generatedDir, 'Package.swift'),
|
||||
join(generatedDir, 'StateServer.swift'),
|
||||
join(generatedDir, 'DebugBridgeManager.swift'),
|
||||
join(generatedDir, 'Bridges.swift'),
|
||||
join(generatedDir, 'DebugOverlay.swift'),
|
||||
join(generatedDir, 'DebugBridgeTouch.m'),
|
||||
join(generatedDir, 'DebugBridgeTouch.h'),
|
||||
join(generatedDir, 'DebugBridgeWiring.swift'),
|
||||
]) {
|
||||
mkdirSync(join(obsolete, '..'), { recursive: true });
|
||||
writeFileSync(obsolete, '// OBSOLETE-HARNESS-SENTINEL\n');
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GSTACK_IOS_CACHE_ROOT: cacheRoot,
|
||||
SWIFT_VERSION: '6.3.3',
|
||||
GEN_ACCESSORS_REV: 'regen-test',
|
||||
};
|
||||
const args = [launcher, '--app-source', appSource, '--bridge-dir', bridgeDir];
|
||||
const first = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stderr).toBe('');
|
||||
|
||||
// Every installed package file must be byte-identical to its explicit
|
||||
// source template: this is the durable template/output parity contract.
|
||||
for (const [template, destination] of SAFE_TEMPLATE_MAP) {
|
||||
expect(readFileSync(join(bridgeDir, destination))).toEqual(
|
||||
readFileSync(join(root, 'ios-qa', 'templates', template)),
|
||||
);
|
||||
}
|
||||
|
||||
const accessorPath = join(generatedDir, 'StateAccessor.swift');
|
||||
const accessor = readFileSync(accessorPath, 'utf8');
|
||||
expect(accessor).toContain('import DebugBridgeCore');
|
||||
expect(accessor).toContain('enum AppStateAccessor');
|
||||
expect(accessor).not.toContain('public enum AppStateAccessor');
|
||||
expect(accessor).not.toContain('import DebugBridge\n');
|
||||
expect(accessor).toContain('guard let restored0 = Self.decodeSnapshotValue(raw0, as: Int.self)');
|
||||
expect(accessor).toContain('atomicRestore: { keys, apply in');
|
||||
expect(accessor).toContain('state.counter = restored0');
|
||||
expect(accessor).toContain('state.counter = typed');
|
||||
expect(accessor).toContain('return true');
|
||||
expect(accessor).not.toContain('atomicRestore: { _ in .ok }');
|
||||
expect(accessor).not.toContain('write: { _ in false }');
|
||||
expect(readFileSync(join(generatedDir, '.gstack-version'), 'utf8')).toBe(
|
||||
readFileSync(join(root, 'VERSION'), 'utf8'),
|
||||
);
|
||||
|
||||
expect(existsSync(join(bridgeDir, 'DebugBridgeWiring.swift'))).toBe(false);
|
||||
expect(existsSync(join(bridgeDir, 'StateAccessor.swift'))).toBe(false);
|
||||
for (const obsoleteName of [
|
||||
'Package.swift',
|
||||
'StateServer.swift',
|
||||
'DebugBridgeManager.swift',
|
||||
'Bridges.swift',
|
||||
'DebugOverlay.swift',
|
||||
'DebugBridgeTouch.m',
|
||||
'DebugBridgeTouch.h',
|
||||
'DebugBridgeWiring.swift',
|
||||
]) {
|
||||
expect(existsSync(join(generatedDir, obsoleteName))).toBe(false);
|
||||
}
|
||||
const installedContents = allFileContents(bridgeDir) + allFileContents(generatedDir);
|
||||
expect(installedContents).not.toContain('FORBIDDEN-WIRING-SENTINEL');
|
||||
expect(installedContents).not.toContain('FORBIDDEN-STATE-SENTINEL');
|
||||
expect(installedContents).not.toContain('OBSOLETE-HARNESS-SENTINEL');
|
||||
|
||||
const swiftAvailable = spawnSync('swift', ['--version'], { encoding: 'utf8' }).status === 0;
|
||||
if (swiftAvailable) {
|
||||
const dump = spawnSync('swift', ['package', 'dump-package', '--package-path', bridgeDir], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(dump.status).toBe(0);
|
||||
const manifest = JSON.parse(dump.stdout) as { targets: Array<{ name: string }> };
|
||||
expect(manifest.targets.map(target => target.name).sort()).toEqual([
|
||||
'DebugBridgeCore',
|
||||
'DebugBridgeTouch',
|
||||
'DebugBridgeUI',
|
||||
]);
|
||||
}
|
||||
|
||||
const firstHash = treeHash(bridgeDir, generatedDir);
|
||||
const firstAccessorHash = accessor.match(/accessorHash: "([a-f0-9]+)"/)?.[1];
|
||||
const second = spawnSync('bash', args, { encoding: 'utf8', env });
|
||||
expect(second.status).toBe(0);
|
||||
expect(second.stderr).toBe('');
|
||||
expect(second.stdout).toContain('gen-accessors: cache hit');
|
||||
expect(treeHash(bridgeDir, generatedDir)).toBe(firstHash);
|
||||
expect(readFileSync(accessorPath, 'utf8').match(/accessorHash: "([a-f0-9]+)"/)?.[1]).toBe(firstAccessorHash);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user