Merge origin/main (v1.60.1.0) into garrytan/gstack-fix-wave

Semantic reconciliation with the parallel time-attack wave (#2264):
- careful: adopt main's anchored full-command whitelist (stricter — also
  catches comment-hiding), re-apply this wave's two hardenings on top
  (capital -[rR] in the flag cluster; exclude `(` and backtick from safe
  targets so $()/backtick substitution cannot ride the whitelist). Union
  of both waves' test batteries passes (main's test.each incl. comment
  case + this wave's substitution/capital-R/FP-pin cases).
- one-way-doors: main landed the singular noun unification (a2a447a1);
  keep this wave's superset (plural s? + --summary-stdin runtime wiring).
- gbrain-local-status: union of states — main's engine-locked (#2194,
  exit 124 PGLite lock) + this wave's thin-client (#2051). --is-ok keeps
  main's intent (engine-locked = STOP) and this wave's (thin-client =
  usable). Test harness unions both fake behaviors.
- sync-gbrain/setup-gbrain tmpls: both Step 1.5 branches kept; generated
  SKILL.md resolved via bun run gen:skill-docs (never hand-edited).
- VERSION/package.json -> 1.61.0.0 per bin/gstack-next-version (main took
  1.60.1.0; PR #2470 claims 1.60.2.0). CHANGELOG: wave entry renumbered
  1.61.0.0 on top of main's 1.60.1.0; careful/#2024 bullets updated to
  describe the delta vs current main. TODOS: union.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-07 14:36:05 -07:00
co-authored by Claude Fable 5
82 changed files with 7234 additions and 883 deletions
+58
View File
@@ -0,0 +1,58 @@
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
// Security regression guard for the basic-ftp transitive dependency.
//
// basic-ftp reaches the tree only transitively:
// puppeteer-core > @puppeteer/browsers > proxy-agent > pac-proxy-agent > get-uri > basic-ftp
//
// Versions <= 5.3.0 carry four HIGH advisories, all fixed in 5.3.1:
// - GHSA-chqc-8p9q-pq6q CVE-2026-39983 FTP command injection via CRLF (fixed 5.2.1)
// - GHSA-6v7q-wjvx-w8wg incomplete CRLF protection, USER/PASS + MKD bypass (fixed 5.2.2)
// - GHSA-rpmf-866q-6p89 DoS via unbounded multiline control-response buffering
// - GHSA-rp42-5vxx-qpwr DoS via unbounded memory in Client.list()
//
// The fix is a bun `overrides` pin (not a phantom direct dependency): overriding
// forces EVERY basic-ftp in the tree to the safe version, including get-uri's
// nested copy. A direct-dependency bump leaves that nested copy behind — which is
// exactly the failure mode this test is here to catch. See the CVE-2026-39983
// upgrade fix for the full rationale.
const MIN_SAFE = '5.3.1';
function cmpSemver(a: string, b: string): number {
const pa = a.split('.').map(Number);
const pb = b.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
describe('basic-ftp security pin (CVE-2026-39983 and siblings)', () => {
test('package.json overrides basic-ftp to a safe version', () => {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
const pin = pkg.overrides?.['basic-ftp'];
expect(pin, 'package.json overrides.basic-ftp must exist').toBeDefined();
const pinned = String(pin).replace(/^[\^~]/, '');
expect(
cmpSemver(pinned, MIN_SAFE) >= 0,
`overrides.basic-ftp is "${pin}" but must be >= ${MIN_SAFE}`,
).toBe(true);
});
test('no basic-ftp entry in bun.lock resolves below the safe version', () => {
const lock = readFileSync(join(ROOT, 'bun.lock'), 'utf-8');
// Match every resolved basic-ftp specifier, including nested paths like
// "get-uri/basic-ftp" that a direct-dependency bump would leave vulnerable.
const versions = [...lock.matchAll(/basic-ftp@(\d+\.\d+\.\d+)/g)].map(m => m[1]);
expect(versions.length, 'expected at least one basic-ftp entry in bun.lock').toBeGreaterThan(0);
const vulnerable = versions.filter(v => cmpSemver(v, MIN_SAFE) < 0);
expect(
vulnerable,
`bun.lock still resolves vulnerable basic-ftp version(s): ${vulnerable.join(', ')}`,
).toEqual([]);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
let tmpHome: string;
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-eval-list-'));
const evalDir = path.join(tmpHome, '.gstack-dev', 'evals');
fs.mkdirSync(evalDir, { recursive: true });
writeEvalRun(evalDir, '2026-a.json', '2026-05-24T01:00:00Z', 2);
writeEvalRun(evalDir, '2026-b.json', '2026-05-24T02:00:00Z', 3);
});
afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
function writeEvalRun(evalDir: string, filename: string, timestamp: string, turns: number) {
fs.writeFileSync(
path.join(evalDir, filename),
JSON.stringify({
schema_version: 1,
version: '1.44.0.0',
branch: 'main',
git_sha: filename,
timestamp,
tier: 'e2e',
total_tests: 1,
passed: 1,
failed: 0,
total_cost_usd: 0,
total_duration_ms: 1000,
tests: [
{
name: filename,
suite: 'sample',
tier: 'e2e',
passed: true,
duration_ms: 1000,
cost_usd: 0,
turns_used: turns,
},
],
}),
);
}
function runEvalList(...args: string[]): { stdout: string; stderr: string; status: number } {
const result = spawnSync('bun', ['run', 'scripts/eval-list.ts', ...args], {
cwd: ROOT,
env: {
...process.env,
HOME: tmpHome,
GSTACK_HOME: path.join(tmpHome, '.gstack'),
},
encoding: 'utf-8',
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status ?? -1,
};
}
describe('eval:list CLI', () => {
test('limits displayed eval runs with a valid positive integer', () => {
const result = runEvalList('--limit', '1');
expect(result.status).toBe(0);
expect(result.stdout).toContain('Eval History (2 total runs)');
expect(result.stdout).toContain('Showing: 1');
expect(result.stdout).toContain('2026-05-24 02:00');
expect(result.stdout).not.toContain('2026-05-24 01:00');
});
test('rejects malformed limit values instead of silently slicing output', () => {
for (const value of ['1abc', 'nope', '0', '-1', '1.5']) {
const result = runEvalList('--limit', value);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('--limit requires a positive integer');
expect(result.stdout).toBe('');
}
});
});
+6
View File
@@ -0,0 +1,6 @@
{
"_schema_version": 1,
"_app_build_id": "uninitialized",
"_accessor_hash": "uninitialized",
"keys": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

@@ -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
@@ -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
@@ -126,6 +126,36 @@ static BOOL DBT_LoadIOKit(void) {
return _IOKitLoaded;
}
// KIF enables accessibility automation before it asks UIKit/SwiftUI for the
// accessibility tree. Without this switch SwiftUI exposes only its hosting
// shell: /elements has no controls and a synthesized tap can return YES while
// Button.action never fires. Keep this DEBUG-only with the rest of this file.
typedef int (*DBTAXSAutomationEnabledFn)(void);
typedef void (*DBTAXSSetAutomationEnabledFn)(int);
static DBTAXSAutomationEnabledFn _DBTAXSAutomationEnabled;
static DBTAXSSetAutomationEnabledFn _DBTAXSSetAutomationEnabled;
static int _DBTInitialAutomationState = -1;
static void DBT_RestoreAccessibilityAutomation(void) {
if (_DBTAXSSetAutomationEnabled && _DBTInitialAutomationState >= 0) {
_DBTAXSSetAutomationEnabled(_DBTInitialAutomationState);
}
}
static void DBT_EnableAccessibilityAutomation(void) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
void *handle = dlopen("/usr/lib/libAccessibility.dylib", RTLD_NOW | RTLD_LOCAL);
if (!handle) return;
_DBTAXSAutomationEnabled = (DBTAXSAutomationEnabledFn)dlsym(handle, "_AXSAutomationEnabled");
_DBTAXSSetAutomationEnabled = (DBTAXSSetAutomationEnabledFn)dlsym(handle, "_AXSSetAutomationEnabled");
if (!_DBTAXSAutomationEnabled || !_DBTAXSSetAutomationEnabled) return;
_DBTInitialAutomationState = _DBTAXSAutomationEnabled();
if (!_DBTInitialAutomationState) _DBTAXSSetAutomationEnabled(1);
atexit(DBT_RestoreAccessibilityAutomation);
});
}
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) CF_RETURNS_RETAINED;
static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
if (!DBT_LoadIOKit()) return NULL;
@@ -172,6 +202,7 @@ static IOHIDEventRef DBT_IOHIDEventWithTouch(UITouch *touch) {
- (void)setGestureView:(UIView *)view;
- (void)_setLocationInWindow:(CGPoint)location resetPrevious:(BOOL)resetPrevious;
- (void)_setIsFirstTouchForView:(BOOL)firstTouchForView;
- (void)_setIsTapToClick:(BOOL)tapToClick;
- (void)_setHidEvent:(IOHIDEventRef)event;
@end
@@ -229,6 +260,10 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
@implementation DebugBridgeTouch
+ (void)prepareForAutomation {
DBT_EnableAccessibilityAutomation();
}
+ (BOOL)sendTapAtPoint:(CGPoint)point inWindow:(UIWindow *)window {
if (!window) return NO;
@@ -247,10 +282,17 @@ static id DBT_HitTestView(UIWindow *window, CGPoint point) {
[touch setPhase:UITouchPhaseBegan];
if ([touch respondsToSelector:@selector(_setIsFirstTouchForView:)]) {
[touch _setIsFirstTouchForView:YES];
} else if ([touch respondsToSelector:@selector(_setIsTapToClick:)]) {
[touch _setIsTapToClick:YES];
Ivar flagsIvar = class_getInstanceVariable([UITouch class], "_touchFlags");
if (flagsIvar) {
ptrdiff_t offset = ivar_getOffset(flagsIvar);
char *flags = (__bridge void *)touch + offset;
*flags |= (char)0x01;
}
}
[touch setTimestamp:[[NSProcessInfo processInfo] systemUptime]];
if ([touch respondsToSelector:@selector(setGestureView:)] &&
[hit isKindOfClass:[UIView class]]) {
if ([touch respondsToSelector:@selector(setGestureView:)]) {
[touch setGestureView:(UIView *)hit];
}
@@ -23,6 +23,10 @@ NS_ASSUME_NONNULL_BEGIN
@interface DebugBridgeTouch : NSObject
/// Enable the in-process accessibility automation tree used by SwiftUI and
/// UIKit. Call once while installing the debug bridge, before /elements.
+ (void)prepareForAutomation;
/// Synthesize a single tap (TouchPhaseBegan + TouchPhaseEnded) at the given
/// window-coordinate point. Returns YES if the touch was delivered (a hit
/// view was found and the event passed through UIApplication.sendEvent).
@@ -27,12 +27,35 @@ public enum DebugBridgeUIWiring {
/// times reinstalls the same closures. Must be called on @MainActor
/// because every UIKit access requires the main actor.
public static func installAll() {
// KIF turns on accessibility automation before walking SwiftUI's AX
// tree. Without it SwiftUI exposes only the hosting shell and taps
// can report success without invoking Button.action.
DebugBridgeTouch.prepareForAutomation()
ScreenshotBridge.resolver = { ScreenshotBridgeImpl.capturePNG() }
ElementsBridge.resolver = { ElementsBridgeImpl.snapshot() }
MutationBridge.resolver = { op, payload in MutationBridgeImpl.dispatch(op: op, payload: payload) }
}
}
/// Return the children UIKit exposes specifically to accessibility automation.
/// iOS 17 added `automationElements`; unlike the older container APIs it
/// preserves identified SwiftUI descendants inside accessibility groups.
@MainActor
private func debugBridgeAccessibilityChildren(of element: NSObject) -> [NSObject] {
if #available(iOS 17.0, *),
let automation = element.automationElements,
!automation.isEmpty {
return automation.compactMap { $0 as? NSObject }
}
if let accessibility = element.accessibilityElements,
!accessibility.isEmpty {
return accessibility.compactMap { $0 as? NSObject }
}
let count = element.accessibilityElementCount()
guard count > 0, count < 512 else { return [] }
return (0..<count).compactMap { element.accessibilityElement(at: $0) as? NSObject }
}
// MARK: - ScreenshotBridge implementation
@MainActor
@@ -43,7 +66,11 @@ enum ScreenshotBridgeImpl {
static func capturePNG() -> Data? {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return nil }
let bounds = window.bounds
let renderer = UIGraphicsImageRenderer(bounds: bounds)
let format = UIGraphicsImageRendererFormat.default()
// /tap consumes UIKit window points. Render at 1x so screenshot pixels
// use that same coordinate space on 2x/3x devices.
format.scale = 1
let renderer = UIGraphicsImageRenderer(bounds: bounds, format: format)
let image = renderer.image { _ in
// drawHierarchy is the documented way to snapshot real UIKit
// layers including layer-backed views. afterScreenUpdates: false
@@ -61,7 +88,10 @@ enum ScreenshotBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -76,21 +106,40 @@ enum ElementsBridgeImpl {
static func snapshot() -> [JSONDict] {
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return [] }
var elements: [JSONDict] = []
collect(view: window, parentPath: "", windowBounds: window.bounds, into: &elements)
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
collect(
view: window,
parentPath: "",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
return elements
}
private static func collect(view: UIView, parentPath: String, windowBounds: CGRect, into elements: inout [JSONDict]) {
private static func collect(
view: UIView,
parentPath: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(view)).inserted else { return }
remaining -= 1
// Skip hidden / zero-size / off-screen subtrees early.
if view.isHidden || view.alpha < 0.01 { return }
let frameInWindow = view.convert(view.bounds, to: nil)
if !windowBounds.intersects(frameInWindow) { return }
let frameInWindow = view.convert(view.bounds, to: window)
if !window.bounds.intersects(frameInWindow) { return }
let isAccessible = view.isAccessibilityElement
let label = view.accessibilityLabel ?? ""
let identifier = view.accessibilityIdentifier ?? ""
let traits = Int(view.accessibilityTraits.rawValue)
let traits = NSNumber(value: view.accessibilityTraits.rawValue)
let value = (view.accessibilityValue ?? "") as String
let className = String(describing: type(of: view))
let path = parentPath.isEmpty ? className : "\(parentPath) > \(className)"
@@ -120,66 +169,98 @@ enum ElementsBridgeImpl {
])
}
// Recurse into accessibility-elements first (some custom views vend
// synthetic children), then UIView subviews. SwiftUI's host views
// populate accessibilityElements lazily many return nil before
// VoiceOver triggers them. Force population by reading accessibilityElementCount.
_ = view.accessibilityElementCount()
if let axElements = view.accessibilityElements {
for case let element as NSObject in axElements {
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
// Synthetic accessibility element (no UIView). Capture frame in screen coords.
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <synthetic>",
"class": "AccessibilityElement",
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
}
} else {
// accessibilityElements is nil iterate by index. SwiftUI uses
// this dynamic protocol pattern; many AX elements only respond
// to accessibilityElementCount + accessibilityElement(at:).
let count = view.accessibilityElementCount()
for i in 0..<count {
guard let element = view.accessibilityElement(at: i) as? NSObject else { continue }
if let v = element as? UIView {
collect(view: v, parentPath: path, windowBounds: windowBounds, into: &elements)
} else {
let af = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
elements.append([
"path": "\(path) > <ax\(i)>",
"class": String(describing: type(of: element)),
"label": (element.value(forKey: "accessibilityLabel") as? String) ?? "",
"identifier": (element.value(forKey: "accessibilityIdentifier") as? String) ?? "",
"value": (element.value(forKey: "accessibilityValue") as? String) ?? "",
"traits": (element.value(forKey: "accessibilityTraits") as? NSNumber)?.intValue ?? 0,
"frame": [
"x": Int(af.origin.x),
"y": Int(af.origin.y),
"w": Int(af.size.width),
"h": Int(af.size.height),
],
"is_user_interaction_enabled": true,
])
}
// Walk automation children before raw subviews. On iOS 17+ this
// exposes identified SwiftUI controls nested inside GroupBox/List.
for (index, element) in debugBridgeAccessibilityChildren(of: view).enumerated() {
if let child = element as? UIView {
collect(
view: child,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
element,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
for sub in view.subviews {
collect(view: sub, parentPath: path, windowBounds: windowBounds, into: &elements)
collect(
view: sub,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
private static func appendSynthetic(
_ element: NSObject,
path: String,
window: UIWindow,
visited: inout Set<ObjectIdentifier>,
remaining: inout Int,
into elements: inout [JSONDict]
) {
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
remaining -= 1
let screenFrame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
let frame = window.coordinateSpace.convert(screenFrame, from: window.screen.coordinateSpace)
let label = (element.value(forKey: "accessibilityLabel") as? String) ?? ""
let identifier = (element.value(forKey: "accessibilityIdentifier") as? String) ?? ""
let value = (element.value(forKey: "accessibilityValue") as? String) ?? ""
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
if !label.isEmpty || !identifier.isEmpty || !value.isEmpty || traits != 0 {
elements.append([
"path": path,
"class": String(describing: type(of: element)),
"label": label,
"identifier": identifier,
"value": value,
"traits": NSNumber(value: traits),
"frame": [
"x": Int(frame.origin.x),
"y": Int(frame.origin.y),
"w": Int(frame.size.width),
"h": Int(frame.size.height),
],
"is_user_interaction_enabled": true,
])
}
// Synthetic SwiftUI nodes are themselves accessibility containers.
// Recurse even when this grouping node has no metadata of its own.
for (index, child) in debugBridgeAccessibilityChildren(of: element).enumerated() {
if let childView = child as? UIView {
collect(
view: childView,
parentPath: path,
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
} else {
appendSynthetic(
child,
path: "\(path) > <ax\(index)>",
window: window,
visited: &visited,
remaining: &remaining,
into: &elements
)
}
}
}
@@ -191,7 +272,10 @@ enum ElementsBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -210,21 +294,62 @@ enum MutationBridgeImpl {
}
}
/// Tap at (x, y) in window coordinates. Delegates to DebugBridgeTouch
/// (KIF-derived in-process touch synthesis). The Obj-C target builds a
/// real UITouch + IOHIDEvent + UIEvent and dispatches via
/// `UIApplication.sendEvent`, which is what UIKit uses for real touches.
/// This works for UIControl, SwiftUI Button (via iOS 18+
/// `_UIHitTestContext`), gesture recognizers, and anything else that
/// listens to the real event-dispatch path.
/// Tap at (x, y) in window coordinates. Prefer accessibility activation,
/// which is stable for SwiftUI buttons across OS releases, then fall back
/// to KIF-derived UITouch synthesis for gesture-only/custom controls.
private static func handleTap(_ payload: JSONDict) -> Bool {
guard let x = payload["x"] as? NSNumber,
let y = payload["y"] as? NSNumber else { return false }
let point = CGPoint(x: x.doubleValue, y: y.doubleValue)
guard let scene = activeScene(), let window = activeKeyWindow(in: scene) else { return false }
if let element = findActivatableAXElement(at: point, in: window),
element.accessibilityActivate() {
return true
}
return DebugBridgeTouch.sendTap(at: point, in: window)
}
private static func findActivatableAXElement(at point: CGPoint, in window: UIWindow) -> NSObject? {
let screenPoint = window.screen.coordinateSpace.convert(point, from: window.coordinateSpace)
var best: NSObject?
var bestArea: CGFloat = .infinity
var visited = Set<ObjectIdentifier>()
var remaining = 2_048
func consider(frame: CGRect, traits: UInt64, element: NSObject) {
guard frame.contains(screenPoint),
(traits & UIAccessibilityTraits.button.rawValue) != 0 else { return }
let area = frame.width * frame.height
if area > 0 && area < bestArea {
best = element
bestArea = area
}
}
func visit(_ element: NSObject) {
guard remaining > 0, visited.insert(ObjectIdentifier(element)).inserted else { return }
remaining -= 1
if let view = element as? UIView {
guard !view.isHidden, view.alpha >= 0.01,
view.convert(view.bounds, to: window).contains(point) else { return }
if view.isAccessibilityElement {
consider(frame: view.accessibilityFrame, traits: view.accessibilityTraits.rawValue, element: view)
}
for child in debugBridgeAccessibilityChildren(of: view) { visit(child) }
for child in view.subviews { visit(child) }
} else {
let frame = (element.value(forKey: "accessibilityFrame") as? CGRect) ?? .zero
let traits = (element.value(forKey: "accessibilityTraits") as? NSNumber)?.uint64Value ?? 0
consider(frame: frame, traits: traits, element: element)
for child in debugBridgeAccessibilityChildren(of: element) { visit(child) }
}
}
visit(window)
return best
}
/// Set text on the first responder if it's a UITextField or UITextView.
private static func handleType(_ payload: JSONDict) -> Bool {
guard let text = payload["text"] as? String else { return false }
@@ -266,7 +391,10 @@ enum MutationBridgeImpl {
var off = scroll.contentOffset
off.x = max(0, min(scroll.contentSize.width - scroll.bounds.width, off.x + dx))
off.y = max(0, min(scroll.contentSize.height - scroll.bounds.height, off.y + dy))
scroll.setContentOffset(off, animated: true)
// Automation commands return synchronously; do not report
// success while the target is still moving underneath the
// next tap coordinate.
scroll.setContentOffset(off, animated: false)
return true
}
node = cur.superview
@@ -301,7 +429,10 @@ enum MutationBridgeImpl {
}
private static func activeKeyWindow(in scene: UIWindowScene) -> UIWindow? {
scene.windows.first(where: { $0.isKeyWindow }) ?? scene.windows.first
let windows = scene.windows.filter { window in
!window.isHidden && !String(describing: type(of: window)).contains("PassThroughWindow")
}
return windows.first(where: { $0.isKeyWindow }) ?? windows.max(by: { $0.windowLevel < $1.windowLevel })
}
}
@@ -1,15 +1,21 @@
// FixtureApp minimal SwiftUI app used by the ios-qa device-path E2E test.
// FixtureApp interaction-rich SwiftUI app used by the ios-qa device-path
// E2E test. Every control exposes a stable accessibility identifier and writes
// to visible verification state so device-driven taps have an explicit oracle.
//
// On launch:
// 1. Boot StateServer (loopback :::1/127.0.0.1 + 9999)
// 2. Log boot token to os_log so devicectl + the Mac daemon can scrape it
// 3. Render a single ContentView so the app stays foreground
// 1. Install UI resolvers before any request can arrive
// 2. Register typed state and boot StateServer (::1/127.0.0.1 + 9999)
// 3. Log the one-use boot token, then render the interaction harness
//
// Everything ios-qa-related is gated #if DEBUG. Release builds compile this
// to a no-op app (no StateServer, no DebugBridge import, no overlay).
import SwiftUI
#if canImport(UIKit)
import UIKit
#endif
#if DEBUG
import DebugBridgeCore
#endif
@@ -20,14 +26,21 @@ import DebugBridgeUI
@main
struct FixtureAppApp: App {
#if DEBUG
private let appState = FixtureAppState()
#endif
init() {
#if DEBUG
StateServer.shared.start()
// Wire the three UIKit-backed bridges so /screenshot, /elements,
// /tap, /type, /swipe actually do something on the device.
// /tap, /type, /swipe are ready before the listener accepts requests.
#if canImport(UIKit)
DebugBridgeUIWiring.installAll()
#endif
DebugBridgeManager.shared.start(
appState: appState,
register: FixtureAppStateAccessor.register
)
#endif
}
@@ -39,22 +52,707 @@ struct FixtureAppApp: App {
}
struct ContentView: View {
@State private var counter: Int = 0
private enum HarnessTab: String, Hashable {
case controls
case inputs
case rows
var title: String { rawValue.capitalized }
}
private struct HarnessRow: Identifiable {
let id: String
let title: String
let symbol: String
}
private static let harnessRows = [
HarnessRow(id: "alpha", title: "Alpha row", symbol: "a.circle.fill"),
HarnessRow(id: "bravo", title: "Bravo row", symbol: "b.circle.fill"),
HarnessRow(id: "charlie", title: "Charlie row", symbol: "c.circle.fill"),
HarnessRow(id: "delta", title: "Delta row", symbol: "d.circle.fill"),
]
@State private var selectedTab: HarnessTab = .controls
@State private var lastAction = "Harness ready"
@State private var totalActions = 0
@State private var tabChangeCount = 0
@State private var primaryButtonCount = 0
@State private var borderedButtonCount = 0
@State private var plainButtonCount = 0
@State private var destructiveButtonCount = 0
@State private var toolbarRefreshCount = 0
@State private var menuAddCount = 0
@State private var menuArchiveCount = 0
@State private var detailOpenCount = 0
@State private var detailCloseCount = 0
@State private var detailButtonCount = 0
@State private var isShowingDetail = false
@State private var toggleValue = false
@State private var toggleChangeCount = 0
@State private var stepperValue = 0
@State private var stepperChangeCount = 0
@State private var selectedMode = "One"
@State private var pickerChangeCount = 0
@State private var draftText = ""
@FocusState private var isTextFieldFocused: Bool
@State private var textChangeCount = 0
@State private var textCommitCount = 0
@State private var uikitButtonCount = 0
@State private var rowTapCounts: [String: Int] = [:]
@State private var rowFlagCount = 0
@State private var rowArchiveCount = 0
@State private var rowToolbarCount = 0
var body: some View {
VStack(spacing: 24) {
Text("ios-qa fixture")
.font(.largeTitle.bold())
Text("StateServer should be on :9999")
.font(.subheadline)
.foregroundColor(.secondary)
Button("Tap (\(counter))") {
counter += 1
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("tap-button")
TabView(selection: $selectedTab) {
controlsTab
.tabItem {
Label("Controls", systemImage: "hand.tap.fill")
.accessibilityIdentifier("tab-controls")
}
.tag(HarnessTab.controls)
inputsTab
.tabItem {
Label("Inputs", systemImage: "slider.horizontal.3")
.accessibilityIdentifier("tab-inputs")
}
.tag(HarnessTab.inputs)
rowsTab
.tabItem {
Label("Rows", systemImage: "list.bullet.rectangle")
.accessibilityIdentifier("tab-rows")
}
.tag(HarnessTab.rows)
}
.padding()
.accessibilityIdentifier("fixture-content")
.accessibilityIdentifier("fixture-tab-view")
.onChange(of: selectedTab) { newTab in
tabChangeCount += 1
record("Selected \(newTab.title) tab")
}
}
private var controlsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: 10
) {
Button {
primaryButtonCount += 1
record("Primary button tapped")
} label: {
buttonLabel("Primary", count: primaryButtonCount, symbol: "bolt.fill")
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("primary-button")
.accessibilityValue("\(primaryButtonCount) taps")
Button {
borderedButtonCount += 1
record("Bordered button tapped")
} label: {
buttonLabel("Bordered", count: borderedButtonCount, symbol: "square")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("bordered-button")
.accessibilityValue("\(borderedButtonCount) taps")
Button {
plainButtonCount += 1
record("Plain button tapped")
} label: {
buttonLabel("Plain", count: plainButtonCount, symbol: "circle")
.padding(.vertical, 8)
.background(Color.accentColor.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.accessibilityIdentifier("plain-button")
.accessibilityValue("\(plainButtonCount) taps")
Button(role: .destructive) {
destructiveButtonCount += 1
record("Destructive-style button tapped")
} label: {
buttonLabel(
"Destructive",
count: destructiveButtonCount,
symbol: "exclamationmark.triangle.fill"
)
}
.buttonStyle(.bordered)
.accessibilityIdentifier("destructive-button")
.accessibilityValue("\(destructiveButtonCount) taps")
}
} label: {
Label("SwiftUI button styles", systemImage: "rectangle.3.group")
.font(.headline)
}
.accessibilityIdentifier("button-styles-group")
GroupBox {
VStack(spacing: 10) {
verificationRow(
"Refresh",
value: toolbarRefreshCount,
identifier: "nav-refresh-count"
)
verificationRow(
"Menu add",
value: menuAddCount,
identifier: "menu-add-count"
)
verificationRow(
"Menu archive",
value: menuArchiveCount,
identifier: "menu-archive-count"
)
verificationRow(
"Detail opened / closed",
textValue: "\(detailOpenCount) / \(detailCloseCount)",
identifier: "detail-navigation-count"
)
}
} label: {
Label("Navigation and menu results", systemImage: "menubar.rectangle")
.font(.headline)
}
.accessibilityIdentifier("navigation-results-group")
Button {
detailOpenCount += 1
isShowingDetail = true
} label: {
Label("Open detail screen (\(detailOpenCount))", systemImage: "chevron.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.indigo)
.accessibilityIdentifier("open-detail-button")
.accessibilityValue("Opened \(detailOpenCount) times")
}
.padding()
}
.accessibilityIdentifier("controls-scroll-view")
.navigationTitle("Tap Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
toolbarRefreshCount += 1
record("Navigation refresh tapped")
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.accessibilityIdentifier("nav-refresh-button")
.accessibilityValue("\(toolbarRefreshCount) refreshes")
}
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button {
menuAddCount += 1
record("Add menu item selected")
} label: {
Label("Add item (\(menuAddCount))", systemImage: "plus")
}
.accessibilityIdentifier("menu-add-item")
Button {
menuArchiveCount += 1
record("Archive menu item selected")
} label: {
Label("Archive item (\(menuArchiveCount))", systemImage: "archivebox")
}
.accessibilityIdentifier("menu-archive-item")
} label: {
Image(systemName: "ellipsis.circle")
.accessibilityLabel("Harness actions menu")
}
.accessibilityIdentifier("toolbar-actions-menu")
.accessibilityValue("Add \(menuAddCount), archive \(menuArchiveCount)")
}
}
.navigationDestination(isPresented: $isShowingDetail) {
VStack(spacing: 18) {
verificationPanel
Image(systemName: "rectangle.stack.badge.play.fill")
.font(.system(size: 46))
.foregroundColor(.indigo)
.accessibilityIdentifier("detail-screen-artwork")
Text("Navigation destination")
.font(.title2.bold())
.accessibilityIdentifier("detail-screen-title")
Button {
detailButtonCount += 1
record("Detail button tapped")
} label: {
Label("Detail tap (\(detailButtonCount))", systemImage: "hand.tap")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("detail-action-button")
.accessibilityValue("\(detailButtonCount) taps")
Text("Detail count: \(detailButtonCount)")
.font(.headline.monospacedDigit())
.accessibilityIdentifier("detail-action-count")
}
.padding()
.navigationTitle("Detail")
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
isShowingDetail = false
} label: {
Label("Back to Tap Lab", systemImage: "chevron.backward")
}
.accessibilityIdentifier("detail-back-button")
}
}
}
.onChange(of: isShowingDetail) { isShowing in
if isShowing {
record("Opened detail screen")
} else {
detailCloseCount += 1
record("Closed detail screen")
}
}
}
.accessibilityIdentifier("controls-navigation-stack")
}
private var inputsTab: some View {
NavigationStack {
ScrollView {
VStack(spacing: 14) {
verificationPanel
GroupBox {
VStack(spacing: 14) {
Toggle(isOn: Binding(
get: { toggleValue },
set: { newValue in
toggleValue = newValue
toggleChangeCount += 1
record("Toggle changed to \(newValue ? "on" : "off")")
}
)) {
Label("Harness toggle", systemImage: toggleValue ? "checkmark.circle.fill" : "circle")
}
.accessibilityIdentifier("harness-toggle")
.accessibilityValue(toggleValue ? "On" : "Off")
verificationRow(
"Toggle changes",
value: toggleChangeCount,
identifier: "toggle-change-count"
)
Divider()
Stepper(
value: Binding(
get: { stepperValue },
set: { newValue in
let direction = newValue > stepperValue ? "up" : "down"
stepperValue = newValue
stepperChangeCount += 1
record("Stepper moved \(direction) to \(newValue)")
}
),
in: 0...9
) {
Label("Stepper value: \(stepperValue)", systemImage: "plusminus.circle")
.monospacedDigit()
}
.accessibilityIdentifier("harness-stepper")
.accessibilityValue("Value \(stepperValue), changed \(stepperChangeCount) times")
verificationRow(
"Stepper changes",
value: stepperChangeCount,
identifier: "stepper-change-count"
)
verificationRow(
"Stepper value",
value: stepperValue,
identifier: "stepper-value"
)
}
} label: {
Label("Toggle and stepper", systemImage: "switch.2")
.font(.headline)
}
.accessibilityIdentifier("toggle-stepper-group")
GroupBox {
VStack(alignment: .leading, spacing: 12) {
Picker("Harness mode", selection: Binding(
get: { selectedMode },
set: { newValue in
selectedMode = newValue
pickerChangeCount += 1
record("Segment selected: \(newValue)")
}
)) {
Text("One").tag("One")
Text("Two").tag("Two")
Text("Three").tag("Three")
}
.pickerStyle(.segmented)
.accessibilityIdentifier("harness-segmented-picker")
.accessibilityValue("Selected \(selectedMode)")
verificationRow(
"Selected segment",
textValue: selectedMode,
identifier: "picker-selection-value"
)
verificationRow(
"Segment changes",
value: pickerChangeCount,
identifier: "picker-change-count"
)
}
} label: {
Label("Segmented picker", systemImage: "rectangle.split.3x1")
.font(.headline)
}
.accessibilityIdentifier("picker-group")
GroupBox {
VStack(alignment: .leading, spacing: 10) {
TextField("Type a device message", text: $draftText)
.focused($isTextFieldFocused)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.done)
.accessibilityIdentifier("harness-text-field")
.accessibilityValue(draftText)
.onSubmit {
commitText(source: "keyboard submit")
}
.onChange(of: draftText) { newValue in
textChangeCount += 1
record("Text changed to \(newValue.isEmpty ? "empty" : newValue)")
}
HStack {
Text("Echo: \(draftText.isEmpty ? "<empty>" : draftText)")
.font(.subheadline.monospaced())
.lineLimit(1)
.accessibilityIdentifier("text-echo-value")
.accessibilityValue(draftText)
Spacer()
Button("Commit") {
commitText(source: "commit button")
}
.buttonStyle(.bordered)
.accessibilityIdentifier("commit-text-button")
.accessibilityValue("Committed \(textCommitCount) times")
}
verificationRow(
"Text changes",
value: textChangeCount,
identifier: "text-change-count"
)
verificationRow(
"Text commits",
value: textCommitCount,
identifier: "text-commit-count"
)
}
} label: {
Label("Text entry", systemImage: "keyboard")
.font(.headline)
}
.accessibilityIdentifier("text-entry-group")
#if canImport(UIKit)
GroupBox {
VStack(spacing: 10) {
UIKitHarnessButton(count: uikitButtonCount) {
uikitButtonCount += 1
record("UIKit UIButton tapped")
}
.frame(maxWidth: .infinity, minHeight: 48)
verificationRow(
"UIKit taps",
value: uikitButtonCount,
identifier: "uikit-button-count"
)
}
} label: {
Label("Native UIKit control", systemImage: "iphone")
.font(.headline)
}
.accessibilityIdentifier("uikit-control-group")
#endif
}
.padding()
}
.accessibilityIdentifier("inputs-scroll-view")
.navigationTitle("Input Lab")
.navigationBarTitleDisplayMode(.inline)
}
.accessibilityIdentifier("inputs-navigation-stack")
}
private var rowsTab: some View {
NavigationStack {
List {
Section {
verificationPanel
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
}
Section("Tap a row") {
ForEach(Self.harnessRows) { row in
Button {
let count = rowTapCounts[row.id, default: 0] + 1
rowTapCounts[row.id] = count
record("\(row.title) tapped")
} label: {
HStack(spacing: 12) {
Image(systemName: row.symbol)
.foregroundColor(.accentColor)
Text(row.title)
Spacer()
Text("\(rowTapCounts[row.id, default: 0])")
.font(.headline.monospacedDigit())
.foregroundColor(.secondary)
.accessibilityIdentifier("row-\(row.id)-count")
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("row-\(row.id)-button")
.accessibilityValue("\(rowTapCounts[row.id, default: 0]) taps")
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
rowFlagCount += 1
record("Flagged \(row.title)")
} label: {
Label("Flag", systemImage: "flag.fill")
}
.tint(.orange)
.accessibilityIdentifier("row-\(row.id)-flag-action")
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
rowArchiveCount += 1
record("Archived \(row.title)")
} label: {
Label("Archive", systemImage: "archivebox.fill")
}
.tint(.indigo)
.accessibilityIdentifier("row-\(row.id)-archive-action")
}
}
}
Section("Row action results") {
verificationRow(
"Flags",
value: rowFlagCount,
identifier: "row-flag-count"
)
verificationRow(
"Archives",
value: rowArchiveCount,
identifier: "row-archive-count"
)
verificationRow(
"Toolbar checks",
value: rowToolbarCount,
identifier: "row-toolbar-count"
)
}
}
.listStyle(.insetGrouped)
.accessibilityIdentifier("rows-list")
.navigationTitle("Row Lab")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
rowToolbarCount += 1
record("Rows toolbar check tapped")
} label: {
Label("Check rows", systemImage: "checkmark.circle")
}
.accessibilityIdentifier("rows-toolbar-button")
.accessibilityValue("\(rowToolbarCount) checks")
}
}
}
.accessibilityIdentifier("rows-navigation-stack")
}
private var verificationPanel: some View {
VerificationPanel(
lastAction: lastAction,
totalActions: totalActions,
selectedTab: selectedTab.title,
tabChangeCount: tabChangeCount
)
}
private func buttonLabel(_ title: String, count: Int, symbol: String) -> some View {
VStack(spacing: 4) {
Label(title, systemImage: symbol)
.lineLimit(1)
Text("Count \(count)")
.font(.caption.monospacedDigit())
.accessibilityIdentifier("\(title.lowercased())-button-count")
}
.frame(maxWidth: .infinity, minHeight: 40)
}
private func verificationRow(_ label: String, value: Int, identifier: String) -> some View {
verificationRow(label, textValue: String(value), identifier: identifier)
}
private func verificationRow(_ label: String, textValue: String, identifier: String) -> some View {
HStack {
Text(label)
.foregroundColor(.secondary)
Spacer()
Text(textValue)
.font(.subheadline.bold().monospacedDigit())
.accessibilityIdentifier(identifier)
.accessibilityLabel(label)
.accessibilityValue(textValue)
}
}
private func commitText(source: String) {
textCommitCount += 1
isTextFieldFocused = false
record("Text committed from \(source): \(draftText.isEmpty ? "empty" : draftText)")
}
private func record(_ action: String) {
totalActions += 1
lastAction = action
}
}
private struct VerificationPanel: View {
let lastAction: String
let totalActions: Int
let selectedTab: String
let tabChangeCount: Int
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("LIVE", systemImage: "waveform.path.ecg")
.font(.caption.bold())
.foregroundColor(.green)
.accessibilityIdentifier("harness-live-indicator")
Spacer()
Text("Total \(totalActions)")
.font(.caption.bold().monospacedDigit())
.accessibilityIdentifier("total-action-count")
.accessibilityLabel("Total actions")
.accessibilityValue("\(totalActions)")
}
Text(lastAction)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
.accessibilityIdentifier("last-action-status")
.accessibilityLabel("Last action")
.accessibilityValue(lastAction)
HStack {
Text("Tab: \(selectedTab)")
.accessibilityIdentifier("selected-tab-status")
.accessibilityValue(selectedTab)
Spacer()
Text("Tab changes: \(tabChangeCount)")
.monospacedDigit()
.accessibilityIdentifier("tab-change-count")
.accessibilityValue("\(tabChangeCount)")
}
.font(.caption)
.foregroundColor(.secondary)
}
.padding(12)
.background(Color.green.opacity(0.10))
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(Color.green.opacity(0.35), lineWidth: 1)
}
.clipShape(RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .contain)
.accessibilityIdentifier("verification-panel")
}
}
#if canImport(UIKit)
private struct UIKitHarnessButton: UIViewRepresentable {
let count: Int
let action: () -> Void
final class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) {
self.action = action
}
@objc func tapped() {
action()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
func makeUIView(context: Context) -> UIButton {
let button = UIButton(type: .system)
var configuration = UIButton.Configuration.filled()
configuration.cornerStyle = .medium
configuration.image = UIImage(systemName: "hand.tap.fill")
configuration.imagePadding = 8
button.configuration = configuration
button.accessibilityIdentifier = "uikit-button"
button.accessibilityLabel = "UIKit button"
button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
return button
}
func updateUIView(_ button: UIButton, context: Context) {
context.coordinator.action = action
var configuration = button.configuration ?? UIButton.Configuration.filled()
configuration.title = "UIKit Tap (\(count))"
button.configuration = configuration
button.accessibilityValue = "\(count) taps"
}
}
#endif
@@ -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() {}
}
+3 -3
View File
@@ -1,7 +1,7 @@
name: FixtureApp
options:
deploymentTarget:
iOS: "16.0"
iOS: "17.0"
bundleIdPrefix: com.gstack.iosqa
developmentLanguage: en
createIntermediateGroups: true
@@ -23,7 +23,7 @@ targets:
FixtureApp:
type: application
platform: iOS
deploymentTarget: "16.0"
deploymentTarget: "17.0"
sources:
- path: Sources/FixtureApp
dependencies:
@@ -45,5 +45,5 @@ targets:
CODE_SIGN_STYLE: Automatic
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "5.9"
IPHONEOS_DEPLOYMENT_TARGET: "16.0"
IPHONEOS_DEPLOYMENT_TARGET: "17.0"
ENABLE_PREVIEWS: YES
+21 -5
View File
@@ -6,13 +6,14 @@
* on PATH that emits canned exit codes + stderr matching the patterns the
* classifier looks for.
*
* Six status cases:
* Seven status cases:
* 1. no-cli — gbrain absent from PATH
* 2. missing-config — gbrain present, config.json absent (honors GBRAIN_HOME)
* 3. broken-config — gbrain present, config exists, stderr contains "config.json"
* 4. broken-db — gbrain present, config exists, stderr contains "Cannot connect to database"
* 5. timeout — probe exceeds GSTACK_GBRAIN_PROBE_TIMEOUT_MS with no recognized error (#1964)
* 6. ok — gbrain present, config exists, sources list returns valid JSON
* 6. engine-locked — PGLite CLI exits 124 because another process owns the DB (#2194)
* 7. ok — gbrain present, config exists, sources list returns valid JSON
*
* Plus cache behavior: hit, TTL expiry, invariant invalidation (HOME change,
* probe-timeout change), --no-cache bypass. Timeout tests keep runtime sane by
@@ -61,7 +62,7 @@ interface FakeEnv {
*/
function makeEnv(opts: {
withGbrain?: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "throws" | "slow" | "thin-refusal";
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal";
withConfig?: boolean;
/** #2051: config carries gbrain's remote_mcp thin-client marker. */
thinClientConfig?: boolean;
@@ -109,7 +110,7 @@ function makeEnv(opts: {
}
function makeFakeGbrainScript(
behavior: "ok" | "broken-db" | "broken-config" | "throws" | "slow" | "thin-refusal",
behavior: "ok" | "broken-db" | "broken-config" | "engine-locked" | "throws" | "slow" | "thin-refusal",
): string {
// "slow": healthy engine on a cold pooler connection (#1964) — sleeps past
// the (test-lowered) probe timeout, then would answer fine.
@@ -132,12 +133,14 @@ exit 0
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
: behavior === "broken-config"
? 'echo "Error: malformed config.json at ~/.gbrain/config.json" >&2'
: behavior === "engine-locked"
? 'echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2'
: behavior === "throws"
? 'echo "unexpected gbrain failure" >&2'
: behavior === "thin-refusal"
? 'echo "Error: gbrain sources is not routable to the remote brain (thin-client of https://brain.example.com/mcp)" >&2'
: "";
const exitCode = behavior === "ok" ? 0 : 1;
const exitCode = behavior === "ok" ? 0 : behavior === "engine-locked" ? 124 : 1;
return `#!/bin/sh
if [ "$1" = "--version" ]; then
echo "gbrain 0.33.1.0"
@@ -234,6 +237,19 @@ describe("lib/gbrain-local-status — status classification", () => {
expect(localEngineStatus({ noCache: true })).toBe("broken-config");
});
it("returns 'engine-locked' when PGLite exits 124 with its own connect timeout (#2194)", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
restoreEnv = applyEnv(env);
expect(localEngineStatus({ noCache: true })).toBe("engine-locked");
});
it("classifies a non-PGLite connect timeout as unreachable DB, not malformed config", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
restoreEnv = applyEnv(env);
writeFileSync(env.configPath, JSON.stringify({ engine: "postgres", database_url: "postgres://fake" }));
expect(localEngineStatus({ noCache: true })).toBe("broken-db");
});
it("returns 'ok' when sources list succeeds", () => {
env = makeEnv({ withGbrain: true, gbrainBehavior: "ok", withConfig: true });
restoreEnv = applyEnv(env);
+18 -1
View File
@@ -42,7 +42,7 @@ interface FakeEnv {
*/
function makeEnv(opts: {
withGbrain: boolean;
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "slow";
gbrainBehavior?: "ok" | "broken-db" | "broken-config" | "engine-locked" | "slow";
withConfig: boolean;
}): FakeEnv {
const tmp = mkdtempSync(join(tmpdir(), "gbrain-sync-skip-"));
@@ -75,6 +75,9 @@ function makeEnv(opts: {
: behavior === "ok"
? ` echo '{"sources":[]}'
exit 0`
: behavior === "engine-locked"
? ` echo "gbrain sources: connect timed out (default 10000ms; pass --timeout=Ns to override)." >&2
exit 124`
: ` ${
behavior === "broken-db"
? 'echo "Cannot connect to database: . Fix: Check your connection URL in ~/.gbrain/config.json" >&2'
@@ -207,6 +210,20 @@ describe("gstack-gbrain-sync — split-engine SKIP (plan D12)", () => {
}
});
it("SKIPs with actionable guidance when PGLite is held by gbrain serve (#2194)", () => {
const env = makeEnv({ withGbrain: true, gbrainBehavior: "engine-locked", withConfig: true });
try {
const r = runOrchestrator(env, ["--code-only"]);
const out = r.stdout + r.stderr;
expect(out).toContain("local engine engine-locked");
expect(out).toContain("gbrain serve");
expect(out).toContain("outside the live Claude session");
expect(out).not.toContain("config.json is malformed");
} finally {
env.cleanup();
}
});
it("SKIPs code stage when gbrain CLI is missing (no-cli)", () => {
const env = makeEnv({ withGbrain: false, withConfig: false });
try {
+80
View File
@@ -67,6 +67,30 @@ describe("canonicalizeRemote", () => {
it("collapses redundant slashes", () => {
expect(canonicalizeRemote("https://github.com//foo//bar")).toBe("github.com/foo/bar");
});
it("strips .git even when the URL has a trailing slash", () => {
// A remote configured with both a .git suffix and a trailing slash must
// canonicalize to the same key as one without — otherwise the same repo
// gets two dedup/source-id keys across machines.
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("git@github.com:garrytan/gstack.git/")).toBe("github.com/garrytan/gstack");
expect(canonicalizeRemote("https://github.com/foo/bar.git///")).toBe("github.com/foo/bar");
});
it("produces the same key with or without a trailing slash", () => {
expect(canonicalizeRemote("https://github.com/garrytan/gstack.git/")).toBe(
canonicalizeRemote("https://github.com/garrytan/gstack.git")
);
});
it("canonicalizes a path remote ending in a .git directory component", () => {
// Stripping the `.git` suffix exposes a new trailing slash
// ("/repo/.git" → "/repo/") which must also be stripped, or the same
// repo splits into two identities.
expect(canonicalizeRemote("file:///Users/x/repo/.git")).toBe(
canonicalizeRemote("file:///Users/x/repo")
);
});
});
// ── secretScanFile ─────────────────────────────────────────────────────────
@@ -244,6 +268,62 @@ body
expect(m!.context_queries[0].id).toBe("complete");
rmSync(dir, { recursive: true, force: true });
});
it("parses a nested filter: block on a list query", () => {
const dir = mkdtempSync(join(tmpdir(), "gstack-test-"));
const file = join(dir, "filtered.md");
writeFileSync(
file,
`---
name: investigate
gbrain:
schema: 1
context_queries:
- id: prior-investigations
kind: list
filter:
type: timeline
tags_contains: "repo:{repo_slug}"
content_contains: "investigate"
sort: updated_at_desc
limit: 5
render_as: "## Prior investigations in this repo"
- id: recent-no-filter
kind: list
sort: created_at_desc
limit: 3
render_as: "## Recent (no filter)"
---
body
`
);
const m = parseSkillManifest(file);
expect(m).not.toBeNull();
expect(m!.context_queries).toHaveLength(2);
// The filter: sub-block is parsed into a key/value map, with quotes
// stripped and template vars left intact for downstream substitution.
const filtered = m!.context_queries[0];
expect(filtered.id).toBe("prior-investigations");
expect(filtered.filter).toEqual({
type: "timeline",
tags_contains: "repo:{repo_slug}",
content_contains: "investigate",
});
// Sibling fields on the same item still parse alongside the filter.
expect(filtered.sort).toBe("updated_at_desc");
expect(filtered.limit).toBe(5);
expect(filtered.render_as).toBe("## Prior investigations in this repo");
// A list query with no filter: leaves filter undefined (no regression).
expect(m!.context_queries[1].id).toBe("recent-no-filter");
expect(m!.context_queries[1].filter).toBeUndefined();
expect(m!.context_queries[1].sort).toBe("created_at_desc");
rmSync(dir, { recursive: true, force: true });
});
});
// ── withErrorContext ───────────────────────────────────────────────────────
+176
View File
@@ -0,0 +1,176 @@
/**
* Project-slug consistency for gstack-repo-mode.
*
* These are end-to-end reproductions of #2212. Each case is a real local Git
* workspace which predates its public GitHub origin: gstack-slug caches the
* local directory name, then the origin is added. repo-mode must write its
* cache next to that canonical project state, not beside a newly derived
* owner-repo slug.
*/
import { describe, expect, test } from 'bun:test';
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
const ROOT = path.resolve(import.meta.dir, '..');
const REPO_MODE_BIN = path.join(ROOT, 'bin', 'gstack-repo-mode');
const SLUG_BIN = path.join(ROOT, 'bin', 'gstack-slug');
type CommandResult = { stdout: string; stderr: string; status: number };
function run(command: string, args: string[], cwd: string, home: string): CommandResult {
const result = spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: { ...process.env, HOME: home, GSTACK_HOME: path.join(home, '.gstack') },
});
return {
stdout: result.stdout ?? '',
stderr: result.stderr ?? '',
status: result.status ?? -1,
};
}
function git(args: string[], cwd: string, home: string) {
const result = run('git', args, cwd, home);
expect(result.status, `${result.stderr}\n${result.stdout}`).toBe(0);
}
function slugFrom(output: string): string {
const line = output.split('\n').find((entry) => entry.startsWith('SLUG='));
expect(line).toBeDefined();
return line!.slice('SLUG='.length);
}
const publicWorkspaces = [
{ name: 'react-local-history', origin: 'https://github.com/facebook/react.git' },
{ name: 'next-local-history', origin: 'https://github.com/vercel/next.js.git' },
{ name: 'kubernetes-local-history', origin: 'https://github.com/kubernetes/kubernetes.git' },
];
describe('gstack-repo-mode cached slug consistency (#2212)', () => {
for (const workspace of publicWorkspaces) {
test(`${workspace.origin} keeps its pre-origin project cache`, () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-'));
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-workspaces-'));
const project = path.join(root, workspace.name);
try {
fs.mkdirSync(project);
git(['init', '-b', 'main'], project, home);
git(['config', 'user.name', 'gstack regression test'], project, home);
git(['config', 'user.email', 'test@gstack.dev'], project, home);
// This workspace existed and used gstack before it adopted GitHub.
const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout);
expect(initialSlug).toBe(workspace.name);
// Five commits make repo-mode perform its real classification path.
for (let commit = 1; commit <= 5; commit += 1) {
fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit));
git(['add', '.'], project, home);
git(['commit', '-m', `commit ${commit}`], project, home);
}
git(['remote', 'add', 'origin', workspace.origin], project, home);
const mode = run(REPO_MODE_BIN, [], project, home);
expect(mode.status, mode.stderr).toBe(0);
expect(mode.stdout.trim()).toBe('REPO_MODE=solo');
// Canonical slug stays the pre-origin basename; remote-derived twin must not appear.
const projectsDir = path.join(home, '.gstack', 'projects');
const canonicalCache = path.join(projectsDir, initialSlug, 'repo-mode.json');
expect(fs.existsSync(canonicalCache)).toBe(true);
expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]);
// Slug remains sanitized even when the remote URL would otherwise diverge.
expect(initialSlug).toMatch(/^[a-zA-Z0-9._-]+$/);
expect(slugFrom(run(SLUG_BIN, [], project, home).stdout)).toBe(initialSlug);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(root, { recursive: true, force: true });
}
});
}
test('still treats a workspace without origin as unknown', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-'));
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-local-only-'));
try {
git(['init', '-b', 'main'], project, home);
const mode = run(REPO_MODE_BIN, [], project, home);
expect(mode.status, mode.stderr).toBe(0);
expect(mode.stdout.trim()).toBe('REPO_MODE=unknown');
expect(fs.existsSync(path.join(home, '.gstack', 'projects'))).toBe(false);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(project, { recursive: true, force: true });
}
});
test('subdir invocation still writes under the git-root cached slug', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-'));
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-subdir-'));
const project = path.join(root, 'nested-history');
const nested = path.join(project, 'packages', 'app');
try {
fs.mkdirSync(nested, { recursive: true });
git(['init', '-b', 'main'], project, home);
git(['config', 'user.name', 'gstack regression test'], project, home);
git(['config', 'user.email', 'test@gstack.dev'], project, home);
const initialSlug = slugFrom(run(SLUG_BIN, [], project, home).stdout);
expect(initialSlug).toBe('nested-history');
for (let commit = 1; commit <= 5; commit += 1) {
fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit));
git(['add', '.'], project, home);
git(['commit', '-m', `commit ${commit}`], project, home);
}
git(['remote', 'add', 'origin', 'https://github.com/facebook/react.git'], project, home);
const mode = run(REPO_MODE_BIN, [], nested, home);
expect(mode.status, mode.stderr).toBe(0);
expect(mode.stdout.trim()).toBe('REPO_MODE=solo');
const projectsDir = path.join(home, '.gstack', 'projects');
expect(fs.existsSync(path.join(projectsDir, initialSlug, 'repo-mode.json'))).toBe(true);
expect(fs.readdirSync(projectsDir)).toEqual([initialSlug]);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(root, { recursive: true, force: true });
}
});
test('sanitizes unusual origin URLs via gstack-slug before mkdir', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-home-'));
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'grepo-weird-url-'));
try {
git(['init', '-b', 'main'], project, home);
git(['config', 'user.name', 'gstack regression test'], project, home);
git(['config', 'user.email', 'test@gstack.dev'], project, home);
for (let commit = 1; commit <= 5; commit += 1) {
fs.writeFileSync(path.join(project, `commit-${commit}.txt`), String(commit));
git(['add', '.'], project, home);
git(['commit', '-m', `commit ${commit}`], project, home);
}
// Free-form remote URL with characters outside [a-zA-Z0-9._-]
git(['remote', 'add', 'origin', 'https://example.com/org/my repo (v2).git'], project, home);
const mode = run(REPO_MODE_BIN, [], project, home);
expect(mode.status, mode.stderr).toBe(0);
expect(mode.stdout.trim()).toBe('REPO_MODE=solo');
const projects = fs.readdirSync(path.join(home, '.gstack', 'projects'));
expect(projects).toHaveLength(1);
expect(projects[0]).toMatch(/^[a-zA-Z0-9._-]+$/);
expect(projects[0]).not.toMatch(/[ ()]/);
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(project, { recursive: true, force: true });
}
});
});
+5 -2
View File
@@ -8,7 +8,8 @@
* Key differences from Codex session-runner:
* - Uses `gemini -p` instead of `codex exec`
* - Output is NDJSON with event types: init, message, tool_use, tool_result, result
* - Uses `--output-format stream-json --yolo` instead of `--json -s read-only`
* - Uses `--output-format stream-json --yolo --skip-trust` instead of `--json -s read-only`
* (`--skip-trust` required for headless/untrusted cwds; see gemini trusted-folders docs)
* - No temp HOME needed — Gemini discovers skills from `.agents/skills/` in cwd
* - Message events are streamed with `delta: true` — must concatenate
*/
@@ -121,7 +122,9 @@ export async function runGeminiSkill(opts: {
}
// Build gemini command
const args = ['-p', prompt, '--output-format', 'stream-json', '--yolo'];
// --skip-trust: headless/CI and temp cwds aren't in ~/.gemini/trustedFolders.json;
// without it gemini exits FatalUntrustedWorkspaceError before any model call.
const args = ['-p', prompt, '--output-format', 'stream-json', '--yolo', '--skip-trust'];
// Spawn gemini — uses real HOME for auth (~/.gemini; HOME is allowlisted),
// cwd for skill discovery. Hermetic scrub with gemini's auth surface
+138
View File
@@ -0,0 +1,138 @@
import { describe, test, expect } from 'bun:test';
import { parseGeminiStreamJson, resultFromGeminiStream } from './gemini';
// Current CLI shape (gemini ≥ ~0.11 / stream-json): content + role, tokens under result.stats
// Documented at https://geminicli.com/docs/cli/headless/ and gemini-cli PR #10883.
const CURRENT_CLI_FIXTURE = [
'{"type":"init","timestamp":"2026-03-20T15:14:46.455Z","session_id":"test-session-123","model":"gemini-2.5-pro"}',
'{"type":"message","role":"user","content":"Reply with exactly the word: PONG"}',
'{"type":"message","role":"assistant","content":"PONG","delta":true}',
'{"type":"result","status":"success","stats":{"input_tokens":24946,"output_tokens":32,"total_tokens":24978}}',
].join('\n');
// Legacy shape: text field, tokens under result.usage
const LEGACY_FIXTURE = [
'{"type":"message","text":"hello"}',
'{"type":"tool_use","name":"run_shell_command"}',
'{"type":"result","usage":{"input_token_count":100,"output_token_count":5},"model":"gemini-2.5-flash"}',
].join('\n');
describe('parseGeminiStreamJson', () => {
test('current CLI: reads assistant content, ignores user echo, stats tokens, init model', () => {
const parsed = parseGeminiStreamJson(CURRENT_CLI_FIXTURE);
expect(parsed.output).toBe('PONG');
expect(parsed.tokens.input).toBe(24946);
expect(parsed.tokens.output).toBe(32);
expect(parsed.modelUsed).toBe('gemini-2.5-pro');
expect(parsed.toolCalls).toBe(0);
});
test('current CLI: user role content must not concat into output', () => {
const raw = [
'{"type":"message","role":"user","content":"PROMPT ECHO"}',
'{"type":"message","role":"assistant","content":"ok","delta":true}',
].join('\n');
const parsed = parseGeminiStreamJson(raw);
expect(parsed.output).toBe('ok');
expect(parsed.output).not.toContain('PROMPT ECHO');
});
test('legacy: still accepts text + usage.input_token_count', () => {
const parsed = parseGeminiStreamJson(LEGACY_FIXTURE);
expect(parsed.output).toBe('hello');
expect(parsed.tokens.input).toBe(100);
expect(parsed.tokens.output).toBe(5);
expect(parsed.toolCalls).toBe(1);
expect(parsed.modelUsed).toBe('gemini-2.5-flash');
});
test('legacy text with role:user is ignored', () => {
const raw = [
'{"type":"message","role":"user","text":"echo"}',
'{"type":"message","role":"assistant","text":"kept"}',
].join('\n');
const parsed = parseGeminiStreamJson(raw);
expect(parsed.output).toBe('kept');
});
test('concatenates multiple assistant content deltas', () => {
const raw = [
'{"type":"message","role":"assistant","content":"A","delta":true}',
'{"type":"message","role":"assistant","content":"B","delta":true}',
].join('\n');
expect(parseGeminiStreamJson(raw).output).toBe('AB');
});
test('skips malformed lines without throwing', () => {
const raw = [
'{"type":"init","model":"m1"}',
'not json',
'{"type":"message","role":"assistant","content":"x","delta":true}',
'{incomplete',
'{"type":"result","stats":{"input_tokens":1,"output_tokens":2}}',
].join('\n');
const parsed = parseGeminiStreamJson(raw);
expect(parsed.output).toBe('x');
expect(parsed.tokens).toEqual({ input: 1, output: 2 });
expect(parsed.modelUsed).toBe('m1');
});
test('empty / whitespace-only input yields empty parse (no throw)', () => {
const parsed = parseGeminiStreamJson('');
expect(parsed.output).toBe('');
expect(parsed.tokens).toEqual({ input: 0, output: 0 });
expect(parsed.toolCalls).toBe(0);
expect(parsed.modelUsed).toBeUndefined();
});
test('result.model overrides init.model when both present', () => {
const raw = [
'{"type":"init","model":"from-init"}',
'{"type":"message","role":"assistant","content":"hi"}',
'{"type":"result","model":"from-result","stats":{"input_tokens":1,"output_tokens":1}}',
].join('\n');
expect(parseGeminiStreamJson(raw).modelUsed).toBe('from-result');
});
});
describe('resultFromGeminiStream (adapter post-CLI e2e)', () => {
test('current CLI fixture → success row with PONG + tokens (not $0)', () => {
const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE, { durationMs: 42 });
expect(result.error).toBeUndefined();
expect(result.output).toBe('PONG');
expect(result.tokens.input).toBe(24946);
expect(result.tokens.output).toBe(32);
expect(result.modelUsed).toBe('gemini-2.5-pro');
expect(result.durationMs).toBe(42);
// Cost signal: non-zero tokens means estimateCost will not be $0.
expect(result.tokens.input + result.tokens.output).toBeGreaterThan(0);
});
test('legacy text-only success still works', () => {
const result = resultFromGeminiStream(LEGACY_FIXTURE, { durationMs: 10 });
expect(result.error).toBeUndefined();
expect(result.output).toBe('hello');
expect(result.toolCalls).toBe(1);
});
test('empty exit-0 stream → error row, never silent $0 success (#2159)', () => {
const emptySuccess = [
'{"type":"init","model":"gemini-2.5-pro"}',
'{"type":"message","role":"user","content":"hi"}',
'{"type":"result","status":"success","stats":{"input_tokens":0,"output_tokens":0}}',
].join('\n');
const result = resultFromGeminiStream(emptySuccess, { durationMs: 5 });
expect(result.error).toBeDefined();
expect(result.error!.code).toBe('unknown');
expect(result.error!.reason).toContain('empty output');
expect(result.output).toBe('');
expect(result.modelUsed).toBe('gemini-2.5-pro');
});
test('pre-fix bug shape (text field missing, only content) would have been empty — now succeeds', () => {
// This is the exact failure mode from #2159: content present, no text.
const result = resultFromGeminiStream(CURRENT_CLI_FIXTURE);
expect(result.error).toBeUndefined();
expect(result.output).toBe('PONG');
});
});
+119 -52
View File
@@ -5,13 +5,108 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export type GeminiStreamParse = {
output: string;
tokens: { input: number; output: number };
toolCalls: number;
modelUsed?: string;
};
/**
* Parse gemini NDJSON stream events (exported for unit tests).
*
* Current CLI (`--output-format stream-json`) emits:
* init → model
* message { role, content, delta? } → concat assistant content
* tool_use → increment toolCalls
* result { stats: { input_tokens, output_tokens } } → tokens
*
* Legacy shape (still accepted):
* message { text } → concat text
* result { usage: { input_token_count, output_token_count } } → tokens
*/
export function parseGeminiStreamJson(raw: string): GeminiStreamParse {
let output = '';
let input = 0;
let out = 0;
let toolCalls = 0;
let modelUsed: string | undefined;
for (const line of raw.split('\n')) {
const s = line.trim();
if (!s) continue;
try {
const obj = JSON.parse(s);
if (obj.type === 'init') {
if (typeof obj.model === 'string' && obj.model) modelUsed = obj.model;
} else if (obj.type === 'message') {
// Current CLI: content + role. Role guard is required — the CLI echoes
// the user prompt as role:'user', which must not land in output.
if (obj.role === 'assistant' && typeof obj.content === 'string') {
output += obj.content;
} else if (typeof obj.text === 'string' && obj.role !== 'user') {
// Legacy text field (no role, or assistant).
output += obj.text;
}
} else if (obj.type === 'tool_use') {
toolCalls += 1;
} else if (obj.type === 'result') {
const u = obj.usage ?? obj.stats ?? {};
input += u.input_token_count ?? u.input_tokens ?? u.prompt_tokens ?? 0;
out += u.output_token_count ?? u.output_tokens ?? u.completion_tokens ?? 0;
if (typeof obj.model === 'string' && obj.model) modelUsed = obj.model;
}
} catch {
// skip malformed lines
}
}
return { output, tokens: { input, output: out }, toolCalls, modelUsed };
}
/**
* Map a raw stream-json dump to a RunResult, including the empty-success
* hardening from #2159. Exported so adapter e2e can exercise the full
* post-CLI path without a live gemini binary.
*/
export function resultFromGeminiStream(
raw: string,
opts: { model?: string; durationMs?: number } = {},
): RunResult {
const parsed = parseGeminiStreamJson(raw);
const modelUsed = parsed.modelUsed || opts.model || 'gemini-2.5-pro';
const durationMs = opts.durationMs ?? 0;
if (!parsed.output.trim()) {
return {
output: '',
tokens: { input: 0, output: 0 },
durationMs,
toolCalls: 0,
modelUsed,
error: { code: 'unknown', reason: 'empty output from gemini CLI (exit 0)' },
};
}
return {
output: parsed.output,
tokens: parsed.tokens,
durationMs,
toolCalls: parsed.toolCalls,
modelUsed,
};
}
/**
* Gemini adapter — wraps the `gemini` CLI.
*
* Gemini CLI auth comes from either ~/.config/gemini/ or GOOGLE_API_KEY. Output
* format is NDJSON with `message`/`tool_use`/`result` events when `--output-format
* stream-json` is requested. This adapter uses a single-response form for simplicity
* in benchmarks; richer streaming lives in gemini-session-runner.ts.
* Auth: GEMINI_API_KEY / GOOGLE_API_KEY (preferred), or ~/.gemini oauth.
* Personal OAuth free-tier is no longer supported by gemini CLI — use an
* AI Studio API key. Antigravity is a separate product/quota path.
*
* Headless flags always passed:
* --output-format stream-json — NDJSON events (message/tool_use/result)
* --yolo — auto-approve tools (non-interactive)
* --skip-trust — trust cwd for this session; required when
* workdir is a temp/untrusted folder (benchmarks
* use mkdtemp). Without it headless gemini exits
* before calling the model.
*/
export class GeminiAdapter implements ProviderAdapter {
readonly name = 'gemini';
@@ -26,9 +121,14 @@ export class GeminiAdapter implements ProviderAdapter {
const newCfgDir = path.join(os.homedir(), '.gemini');
const newOauth = path.join(newCfgDir, 'oauth_creds.json');
const hasCfg = fs.existsSync(legacyCfgDir) || fs.existsSync(newOauth);
const hasKey = !!process.env.GOOGLE_API_KEY;
// CLI accepts either name; Google AI Studio keys are usually GEMINI_API_KEY.
const hasKey = !!(process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY);
if (!hasCfg && !hasKey) {
return { ok: false, reason: 'No Gemini auth found. Log in via `gemini login` or export GOOGLE_API_KEY.' };
return {
ok: false,
reason:
'No Gemini auth found. Export GEMINI_API_KEY (or GOOGLE_API_KEY) from https://aistudio.google.com/app/apikey — personal OAuth free-tier is no longer supported by gemini CLI.',
};
}
return { ok: true };
}
@@ -36,8 +136,10 @@ export class GeminiAdapter implements ProviderAdapter {
async run(opts: RunOpts): Promise<RunResult> {
const start = Date.now();
// Default to --yolo (non-interactive) and stream-json output so we can parse
// tokens + tool calls. Callers can override via extraArgs.
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--yolo'];
// tokens + tool calls. --skip-trust is required for headless/temp workdirs
// (gemini CLI otherwise exits: "not running in a trusted directory").
// Callers can override via extraArgs.
const args = ['-p', opts.prompt, '--output-format', 'stream-json', '--yolo', '--skip-trust'];
if (opts.model) args.push('--model', opts.model);
if (opts.extraArgs) args.push(...opts.extraArgs);
@@ -47,15 +149,15 @@ export class GeminiAdapter implements ProviderAdapter {
timeout: opts.timeoutMs,
encoding: 'utf-8',
maxBuffer: 32 * 1024 * 1024,
env: {
...process.env,
// Prefer GEMINI_API_KEY when only that is set (CLI reads both).
...(process.env.GEMINI_API_KEY && !process.env.GOOGLE_API_KEY
? { GOOGLE_API_KEY: process.env.GEMINI_API_KEY }
: {}),
},
});
const parsed = this.parseStreamJson(out);
return {
output: parsed.output,
tokens: parsed.tokens,
durationMs: Date.now() - start,
toolCalls: parsed.toolCalls,
modelUsed: parsed.modelUsed || opts.model || 'gemini-2.5-pro',
};
return resultFromGeminiStream(out, { model: opts.model, durationMs: Date.now() - start });
} catch (err: unknown) {
const durationMs = Date.now() - start;
const e = err as { code?: string; stderr?: Buffer; signal?: string; message?: string };
@@ -63,7 +165,7 @@ export class GeminiAdapter implements ProviderAdapter {
if (e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT') {
return this.emptyResult(durationMs, { code: 'timeout', reason: `exceeded ${opts.timeoutMs}ms` }, opts.model);
}
if (/unauthorized|auth|login|api key/i.test(stderr)) {
if (/unauthorized|auth|login|api key|ineligibletier|no longer supported/i.test(stderr)) {
return this.emptyResult(durationMs, { code: 'auth', reason: stderr.slice(0, 400) }, opts.model);
}
if (/rate[- ]?limit|429|quota/i.test(stderr)) {
@@ -77,41 +179,6 @@ export class GeminiAdapter implements ProviderAdapter {
return estimateCostUsd(tokens, model ?? 'gemini-2.5-pro');
}
/**
* Parse gemini NDJSON stream events:
* init → session id (discarded here)
* message { delta: true, text } → concat to output
* tool_use { name } → increment toolCalls
* result { usage: { input_token_count, output_token_count } } → tokens
*/
private parseStreamJson(raw: string): { output: string; tokens: { input: number; output: number }; toolCalls: number; modelUsed?: string } {
let output = '';
let input = 0;
let out = 0;
let toolCalls = 0;
let modelUsed: string | undefined;
for (const line of raw.split('\n')) {
const s = line.trim();
if (!s) continue;
try {
const obj = JSON.parse(s);
if (obj.type === 'message' && typeof obj.text === 'string') {
output += obj.text;
} else if (obj.type === 'tool_use') {
toolCalls += 1;
} else if (obj.type === 'result') {
const u = obj.usage ?? {};
input += u.input_token_count ?? u.prompt_tokens ?? 0;
out += u.output_token_count ?? u.completion_tokens ?? 0;
if (obj.model) modelUsed = obj.model;
}
} catch {
// skip malformed lines
}
}
return { output, tokens: { input, output: out }, toolCalls, modelUsed };
}
private emptyResult(durationMs: number, error: RunResult['error'], model?: string): RunResult {
return {
output: '',
+29 -12
View File
@@ -173,13 +173,9 @@ export async function runSkillTest(options: {
// restores operator MCP along with the operator env.
if (isHermeticEnabled()) args.push('--strict-mcp-config');
// Write prompt to a temp file OUTSIDE workingDirectory to avoid race conditions
// where afterAll cleanup deletes the dir before cat reads the file (especially
// with --concurrent --retry). Using os.tmpdir() + unique suffix keeps it stable.
const promptFile = path.join(os.tmpdir(), `.prompt-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(promptFile, prompt);
const proc = Bun.spawn(['sh', '-c', `cat "${promptFile}" | claude ${args.map(a => `"${a}"`).join(' ')}`], {
// Spawn claude directly with array-form args (no shell interpolation).
// Prompt is piped via stdin using a Blob to avoid temp files and shell escaping.
const proc = Bun.spawn(['claude', ...args], {
cwd: workingDirectory,
// Hermetic by default (see test/helpers/hermetic-env.ts): operator
// session context (CONDUCTOR_*, CLAUDECODE, ~/.claude config, ~/.gstack)
@@ -189,6 +185,7 @@ export async function runSkillTest(options: {
// suite exercising the INTERACTIVE prose-fallback path opts out by passing
// `env: { GSTACK_HEADLESS: '' }` — extraEnv wins because it spreads last.
env: hermeticChildEnv({ GSTACK_HEADLESS: '1', ...extraEnv }),
stdin: new Blob([prompt]),
stdout: 'pipe',
stderr: 'pipe',
});
@@ -201,6 +198,13 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill();
// proc.kill() signals claude itself (direct spawn, no shell wrapper),
// but tool subprocesses claude spawned can survive as orphans that
// inherited our stdout/stderr pipes, so without cancel() the read loop
// below blocks until the orphan finally exits (observed: a 600s timeout
// stretching past 1400s and tripping bun's per-test timeout instead of
// returning a result).
reader.cancel().catch(() => { /* stream already closed */ });
}, timeout);
// Stream NDJSON from stdout for real-time progress
@@ -227,6 +231,11 @@ export async function runSkillTest(options: {
if (!line.trim()) continue;
collectedLines.push(line);
// Track time to first NDJSON line (measures latency from spawn to first Claude response)
if (firstResponseMs === 0) {
firstResponseMs = Date.now() - startTime;
}
// Real-time progress to stderr + persistent logs
try {
const event = JSON.parse(line);
@@ -238,8 +247,7 @@ export async function runSkillTest(options: {
liveToolCount++;
const now = Date.now();
const elapsed = Math.round((now - startTime) / 1000);
// Track timing telemetry
if (firstResponseMs === 0) firstResponseMs = now - startTime;
// Track inter-turn latency (tool call to tool call)
if (lastToolTime > 0) {
const interTurn = now - lastToolTime;
if (interTurn > maxInterTurnMs) maxInterTurnMs = interTurn;
@@ -289,12 +297,21 @@ export async function runSkillTest(options: {
collectedLines.push(buf);
}
stderr = await stderrPromise;
// Same orphan hazard as stdout: an orphaned grandchild holding stderr open
// would block the drain forever. Race it against child exit + a short grace
// window; the normal path (pipes close with the child) still wins the race
// and keeps full stderr.
stderr = await Promise.race([
stderrPromise,
(async () => {
await proc.exited;
await new Promise((r) => setTimeout(r, 5_000));
return '';
})(),
]);
const exitCode = await proc.exited;
clearTimeout(timeoutId);
try { fs.unlinkSync(promptFile); } catch { /* non-fatal */ }
if (timedOut) {
exitReason = 'timeout';
} else if (exitCode === 0) {
+23 -9
View File
@@ -97,10 +97,9 @@ describe('check-careful.sh', () => {
expect(output.message).toContain('recursive delete');
});
// Regression: the safe-exception extracts targets from only the LAST `rm` in
// the command (greedy match), so a chain that ends in a safe target must not
// wave through a destructive earlier rm. The shortcut only applies to a
// single rm invocation; any shell separator falls through to the warning.
// The safe exception matches the COMPLETE command against an anchored
// whitelist shape — anything else (chains, comments, substitution) falls
// through to the destructive-pattern warning.
test('rm -rf /; rm -rf node_modules warns (semicolon chain, dangerous first)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /; rm -rf node_modules'));
expect(exitCode).toBe(0);
@@ -122,9 +121,9 @@ describe('check-careful.sh', () => {
expect(output.message).toContain('recursive delete');
});
// Command substitution is a chaining form: the substitution token can end in
// a whitelisted suffix while running anything inside $(...) or backticks,
// and the safe-exception early exit would skip ALL downstream checks.
// Command substitution can end in a whitelisted suffix while running
// anything inside $(...) or backticks — the whitelist's target tokens
// exclude `(` and backtick so these cannot ride the safe exception.
test('rm -rf $(./wipe-all)/node_modules warns (command substitution)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf $(./wipe-all)/node_modules'));
expect(exitCode).toBe(0);
@@ -162,8 +161,8 @@ describe('check-careful.sh', () => {
expect(output.permissionDecision).toBeUndefined();
});
// The JSON-escaped-newline separator branch (literal two-char \n surviving
// the grep extraction path) had dedicated code but no test exercising it.
// JSON-escaped newline (literal two-char \n surviving the grep extraction
// path) breaks the anchored whitelist shape → falls through to the warn.
test('newline-chained rm warns (escaped-newline separator branch)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /etc/x\nrm -rf node_modules'));
expect(exitCode).toBe(0);
@@ -181,6 +180,21 @@ describe('check-careful.sh', () => {
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test.each([
'rm -rf /; rm -rf node_modules',
'rm -rf / && rm -rf node_modules',
'rm -rf / # rm -rf node_modules',
'rm -rf node_modules; rm -rf /',
'rm -rf node_modules || rm -rf /',
'echo ok && rm -rf /',
'rm -rf node_modules\nrm -rf /',
])('never lets a safe-looking target hide a destructive command: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
});
// --- SQL destructive commands ---
+28 -1
View File
@@ -24,8 +24,10 @@ import {
openclaw,
} from '../hosts/index';
import { HOST_PATHS } from '../scripts/resolvers/types';
import { RESOLVERS } from '../scripts/resolvers';
const ROOT = path.resolve(import.meta.dir, '..');
const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS));
// ─── hosts/index.ts ─────────────────────────────────────────
@@ -205,13 +207,32 @@ describe('validateHostConfig', () => {
c.cliCommand = 'opencode;rm -rf /';
expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true);
});
test('valid suppressedResolvers pass when resolver names provided', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY'];
expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]);
});
test('unknown suppressedResolvers entry is caught', () => {
const c = makeValid();
c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER'];
const errors = validateHostConfig(c, RESOLVER_NAMES);
expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true);
});
test('suppressedResolvers unchecked when resolver names omitted', () => {
const c = makeValid();
c.suppressedResolvers = ['TYPO_RESOLVER'];
expect(validateHostConfig(c)).toEqual([]);
});
});
// ─── validateAllConfigs ─────────────────────────────────────
describe('validateAllConfigs', () => {
test('real configs all pass validation', () => {
const errors = validateAllConfigs(ALL_HOST_CONFIGS);
const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES);
expect(errors).toEqual([]);
});
@@ -233,6 +254,12 @@ describe('validateAllConfigs', () => {
expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true);
});
test('unknown suppressedResolvers entry surfaces with host-name prefix', () => {
const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig;
const errors = validateAllConfigs([bad], RESOLVER_NAMES);
expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true);
});
test('per-config validation errors are prefixed with host name', () => {
const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig;
const errors = validateAllConfigs([bad]);
+258
View File
@@ -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);
});
});
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'fs';
import { join } from 'path';
const ROOT = join(import.meta.dir, '..');
const PRE_FIXTURE = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.json');
const PRE_SCREENSHOT = join(ROOT, 'test/fixtures/ios-fix/ios-qa-swiftui-tap-pre.png');
describe('ios-fix regression fixture — SwiftUI taps reported success without acting', () => {
test('preserves the pre-fix state and physical-device screenshot', () => {
const state = JSON.parse(readFileSync(PRE_FIXTURE, 'utf8'));
expect(state).toEqual({
_schema_version: 1,
_app_build_id: 'uninitialized',
_accessor_hash: 'uninitialized',
keys: {},
});
const png = readFileSync(PRE_SCREENSHOT);
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
expect(png.readUInt32BE(16)).toBe(1206);
expect(png.readUInt32BE(20)).toBe(2622);
});
test('keeps the physical-device deploy/tap test opt-in and executable', () => {
const deviceTest = readFileSync(join(ROOT, 'test/skill-e2e-ios-device.test.ts'), 'utf8');
expect(deviceTest).toContain("process.env.GSTACK_IOS_DEVICE_DEPLOY === '1'");
expect(deviceTest).toContain("'primary-button'");
expect(deviceTest).toContain("'/tap'");
expect(deviceTest).not.toContain("test.skip('TODO(deploy)");
});
});
+13
View File
@@ -29,6 +29,19 @@ describe("one-way-door credential keyword net (#1839)", () => {
expect(classifyQuestion({ summary: `rotate my ${noun}` }).oneWay).toBe(true);
}
});
// revoke/reset/rotate must all share the same credential noun list. Previously
// "secret" was only in rotate (missing from revoke and reset) and "access key"
// was missing from reset, so "revoke my secret" / "reset my secret" /
// "reset my access key" leaked through as two-way (auto-decidable).
test("revoke/reset/rotate are all parallel for every credential noun", () => {
for (const verb of ["revoke", "reset", "rotate"]) {
for (const noun of ["api key", "token", "secret", "credential", "access key", "password"]) {
const r = classifyQuestion({ summary: `${verb} my ${noun}` });
expect(r.oneWay, `${verb} my ${noun} should be one-way`).toBe(true);
}
}
});
});
describe("one-way-door credential keyword net (#2024)", () => {
+73
View File
@@ -0,0 +1,73 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { runSkillTest } from './helpers/session-runner';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// Regression test for the runSkillTest timeout path (free tier — no API call,
// the spawned "claude" is a local fake).
//
// proc.kill() only signals the `sh -c` wrapper; the claude child it spawned
// survives as an orphan that inherited our stdout/stderr pipes. Before the
// fix, the runner then blocked on the pipe drain until the orphan exited —
// observed as a 600s spawn timeout stretching past 1400s and tripping bun's
// per-test timeout in skill-e2e-autoplan-dual-voice.test.ts. The fix cancels
// the stdout reader on timeout and races the stderr drain against child exit
// plus a short grace window.
const ORPHAN_LINGER_SECS = 45; // without the fix the runner blocks this long
describe.skipIf(process.platform === 'win32')('session-runner timeout path', () => {
let fixtureBin: string;
let workDir: string;
beforeAll(() => {
fixtureBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-claude-bin-'));
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-runner-timeout-'));
// Fake claude: emits minimal stream-json, spawns a pipe-holding orphan,
// then lingers in the foreground. Killing the sh wrapper leaves both this
// process and its background child alive, holding the pipes open.
const fakeClaude = path.join(fixtureBin, 'claude');
fs.writeFileSync(
fakeClaude,
`#!/bin/sh
cat > /dev/null &
echo '{"type":"system","subtype":"init"}'
echo '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"echo hi"}}]}}'
sleep ${ORPHAN_LINGER_SECS} &
exec sleep ${ORPHAN_LINGER_SECS}
`,
{ mode: 0o755 },
);
});
afterAll(() => {
for (const dir of [fixtureBin, workDir]) {
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
}
});
test(
'returns promptly when a killed child leaves a pipe-holding orphan',
async () => {
const started = Date.now();
const result = await runSkillTest({
testName: 'session-runner-timeout-orphan',
workingDirectory: workDir,
prompt: 'irrelevant — the fake claude ignores stdin',
timeout: 3_000,
env: { PATH: `${fixtureBin}:${process.env.PATH ?? ''}` },
});
const wall = Date.now() - started;
expect(result.exitReason).toBe('timeout');
// Streamed lines collected before the kill must survive the cancel.
expect(result.transcript.some((e: any) => e?.type === 'assistant')).toBe(true);
// Without the fix the runner blocks until the orphan exits (~${ORPHAN_LINGER_SECS}s).
// Timeout (3s) + stderr grace (5s) + slack must stay well under that.
expect(wall).toBeLessThan(20_000);
},
30_000,
);
});
+43 -10
View File
@@ -40,6 +40,19 @@ describeIfSelected('Autoplan dual-voice E2E', ['autoplan-dual-voice'], () => {
copyDirSync(path.join(ROOT, 'plan-design-review'), path.join(workDir, 'plan-design-review'));
copyDirSync(path.join(ROOT, 'plan-devex-review'), path.join(workDir, 'plan-devex-review'));
// Register the skills as project-level slash commands. The root copies
// above are NOT enough on their own: claude -p only discovers skills under
// .claude/skills/, and an unregistered slash command short-circuits with
// "Unknown command: /autoplan" (0 turns, ~1s) on claude >= 2.x — the model
// never runs, so both voice assertions fail. Same install pattern as
// installSkills() in skill-routing-e2e.test.ts.
const skillsBase = path.join(workDir, '.claude', 'skills');
for (const skill of ['autoplan', 'plan-ceo-review', 'plan-eng-review', 'plan-design-review', 'plan-devex-review']) {
const dest = path.join(skillsBase, skill);
fs.mkdirSync(dest, { recursive: true });
fs.copyFileSync(path.join(ROOT, skill, 'SKILL.md'), path.join(dest, 'SKILL.md'));
}
// Write a tiny plan file for /autoplan to review.
planPath = path.join(workDir, 'TEST_PLAN.md');
fs.writeFileSync(planPath, `# Test Plan: add /greet skill
@@ -65,20 +78,24 @@ Add a new /greet skill that prints a welcome message.
test.skipIf(!evalsEnabled)(
'both Claude + Codex voices produce output in Phase 1 (within timeout)',
async () => {
// Fire /autoplan with a 5-min hard timeout on the spawn itself.
// Fire /autoplan with a 10-min hard timeout on the spawn itself.
// The skill itself has 10-min phase timeouts + auth-gate failfast.
// If Codex is unavailable on the test machine, the skill should print
// [codex-unavailable] and still complete the Claude subagent half.
// Budget note: 5 min / 30 turns was enough at v1.0-era skill sizes, but
// the full-depth Phase 1 (registered skill + CEO review subagent) now
// needs longer — at 300s the run was killed mid-CEO-review with both
// voices already fired but no Phase-1-complete marker yet.
const result = await runSkillTest({
testName: 'autoplan-dual-voice',
workingDirectory: workDir,
prompt: `/autoplan ${planPath}`,
timeout: 300_000, // 5 min
timeout: 600_000, // 10 min
// /autoplan spawns subagents and calls codex via Bash; it needs the
// full tool set to get past Phase 1. Bash+Read+Write alone wasn't
// enough — the skill stalled trying to invoke Agent/Skill.
allowedTools: ['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob', 'Agent', 'Skill'],
maxTurns: 30,
maxTurns: 40,
runId,
});
@@ -91,8 +108,14 @@ Add a new /greet skill that prints a welcome message.
// false positives regardless of skill behavior. Filter to tool_result
// content + assistant messages emitted DURING execution.
const transcript = Array.isArray(result.transcript) ? result.transcript : [];
// The transcript holds RAW stream-json events: tool_use blocks live
// INSIDE assistant events' message.content, and tool_results inside
// user events — no top-level entry ever has type 'tool_use'. Filtering
// on that shape matched nothing, silently reducing `out` to
// result.output alone, so a run killed at the spawn timeout (no result
// event) had NOTHING to match and every assertion failed.
const executionContent = transcript
.filter((entry: any) => entry && (entry.type === 'tool_use' || entry.type === 'tool_result' || entry.role === 'assistant'))
.filter((entry: any) => entry && (entry.type === 'assistant' || entry.type === 'user'))
.map((entry: any) => JSON.stringify(entry))
.join('\n');
const out = (result.output ?? '') + '\n' + executionContent;
@@ -112,17 +135,27 @@ Add a new /greet skill that prints a welcome message.
expect(claudeVoiceFired).toBe(true);
expect(codexVoiceFired || codexUnavailable).toBe(true);
// Hang protection: require phase completion evidence, not name mentions.
// "Phase 1 complete" or a phase-transition marker, not "plan-ceo-review"
// as a bare string (which appears in the prompt itself).
// Hang protection: require pipeline-progress evidence, not name mentions.
// Full Phase 1 COMPLETION (three parallel review subagents, each loading a
// 25-35K-token skill) routinely exceeds 10 minutes on sonnet, so requiring
// the "Phase 1 complete" banner would force a 20-minute test for no extra
// dual-voice signal. Accept EITHER the completion banner (autoplan/SKILL.md
// "PHASE 1 COMPLETE" mandatory output) OR structural evidence that the
// Phase 1 review dispatch actually happened: an Agent tool_use whose input
// carries review instructions (execution artifact built by the skill, not
// an echo of our prompt).
const reachedPhase1 = /Phase\s+1\s+(complete|done|finished)|CEO\s+Review\s+(complete|done|approved)|Strategy\s*&\s*Scope\s+(complete|done)|Phase\s+2\s+(started|begin)/i.test(out);
expect(reachedPhase1).toBe(true);
const toolCalls = Array.isArray(result.toolCalls) ? result.toolCalls : [];
const reviewDispatched = toolCalls.some((tc: any) =>
(tc?.tool === 'Agent' || tc?.tool === 'Task') &&
/review|ceo|eng manager|strategy/i.test(JSON.stringify(tc?.input ?? {})));
expect(reachedPhase1 || reviewDispatched).toBe(true);
logCost('autoplan-dual-voice', result);
recordE2E(evalCollector, 'autoplan-dual-voice', 'Autoplan dual-voice E2E', result, {
passed: claudeVoiceFired && (codexVoiceFired || codexUnavailable) && reachedPhase1,
passed: claudeVoiceFired && (codexVoiceFired || codexUnavailable) && (reachedPhase1 || reviewDispatched),
});
},
330_000, // per-test timeout slightly > spawn timeout so cleanup can run
630_000, // per-test timeout slightly > spawn timeout so cleanup can run
);
});
+7 -11
View File
@@ -129,19 +129,15 @@ describeIfEvals('multi-provider benchmark adapters (live)', () => {
if (result.error) {
throw new Error(`gemini errored: ${result.error.code}${result.error.reason}`);
}
// Gemini CLI occasionally returns empty output even on successful runs
// (model returned content the CLI parser missed, intermittent stream issues).
// We assert the adapter ran end-to-end without erroring and reports a non-
// empty token count instead of grepping the literal "ok" — that string
// assertion was too brittle for a smoke that's really about "did the
// adapter wire up and the run terminate successfully?"
expect(typeof result.output).toBe('string');
// Gemini CLI sometimes returns 0 tokens in the result event (older responses);
// assert non-negative instead of strictly positive.
expect(result.tokens.input).toBeGreaterThanOrEqual(0);
expect(result.tokens.output).toBeGreaterThanOrEqual(0);
// Adapter must never report empty-success (#2159). After content/stats
// parsing, a healthy run has non-empty assistant text + token counts.
expect(result.output.trim().length).toBeGreaterThan(0);
expect(result.output.toLowerCase()).toContain('ok');
expect(result.tokens.input).toBeGreaterThan(0);
expect(result.tokens.output).toBeGreaterThan(0);
expect(result.durationMs).toBeGreaterThan(0);
expect(typeof result.modelUsed).toBe('string');
expect(result.modelUsed.length).toBeGreaterThan(0);
}, 150_000);
test('timeout error surfaces as error.code=timeout (no exception)', async () => {
+760 -49
View File
@@ -1,4 +1,8 @@
// GSTACK_HAS_IOS_DEVICE=1 device-path test. Runs only when:
// Real-device tests. The lightweight CoreDevice checks run with
// GSTACK_HAS_IOS_DEVICE=1; the signing/install/interaction smoke test has the
// separate, explicit GSTACK_IOS_DEVICE_DEPLOY=1 opt-in.
//
// Runs only when:
// - An iPhone is connected via USB and reachable through CoreDevice
// - The iPhone is paired (user has tapped "Trust" on the trust dialog)
// - Developer Mode is enabled on the iPhone (Settings → Privacy → Developer Mode)
@@ -10,57 +14,91 @@
// 4. The fixture iOS SPM package builds with `swift build` for iOS target
// (verifies the templates compile against the iOS SDK, not just macOS)
//
// What it does NOT exercise (out of scope for this test):
// - Building + signing a full iOS app via xcodebuild (requires provisioning
// profile + dev team — environment-specific, not portable across CI)
// - Actually deploying + launching the StateServer on the device (same)
//
// The first three steps prove the CoreDevice path is wired end-to-end on the
// agent's side. The fourth proves the Swift templates compile against the
// iOS SDK, not just macOS — which catches UIKit/SwiftUI gating bugs before
// they reach a real app deployment.
// GSTACK_IOS_DEVICE_DEPLOY=1 additionally generates the fixture Xcode project,
// signs it with GSTACK_IOS_DEVELOPMENT_TEAM + GSTACK_IOS_BUNDLE_ID, installs
// and launches it, then proves screenshot/elements/tap through the real daemon.
// It remains skipped in normal CI because signing and a paired iPhone are
// intentionally machine-specific.
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { startDaemon, type RunningDaemon } from '../ios-qa/daemon/src/index';
import { startTunnelKeepalive } from '../ios-qa/daemon/src/devicectl';
import { bootstrapTunnel } from '../ios-qa/daemon/src/tunnel-bootstrap';
import type { DeviceTunnel } from '../ios-qa/daemon/src/proxy';
const ROOT = join(import.meta.dir, '..');
const FIXTURE_PATH = join(ROOT, 'test/fixtures/ios-qa/FixtureApp');
const HAS_DEVICE = process.env.GSTACK_HAS_IOS_DEVICE === '1';
const DEPLOY_TO_DEVICE = process.env.GSTACK_IOS_DEVICE_DEPLOY === '1';
const describeIfDevice = HAS_DEVICE ? describe : describe.skip;
const testIfDeploy = DEPLOY_TO_DEVICE ? test : test.skip;
interface DeviceListEntry {
identifier: string;
state: string; // "available" | "available (pairing)" | "unavailable" | ...
name: string;
model: string;
platform: string;
transport: string;
paired: boolean;
}
interface DeviceElement {
identifier?: string;
label?: string;
value?: string;
frame?: { x: number; y: number; w: number; h: number };
}
interface StateSnapshot {
_app_build_id?: string;
_accessor_hash?: string;
keys?: Record<string, unknown>;
}
function listDevices(): DeviceListEntry[] {
// devicectl JSON output requires --json-output to a path. Use a tempfile.
const tmp = `/tmp/devicectl-list-${process.pid}-${Date.now()}.json`;
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
try {
const fs = require('fs');
const raw = fs.readFileSync(tmp, 'utf-8');
const r = spawnSync('xcrun', ['devicectl', 'list', 'devices', '--json-output', tmp], {
stdio: 'pipe',
timeout: 30_000,
});
if (r.status !== 0) return [];
const raw = readFileSync(tmp, 'utf-8');
const obj = JSON.parse(raw);
fs.unlinkSync(tmp);
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string } }) => ({
return (obj.result?.devices ?? []).map((d: { identifier: string; connectionProperties: { tunnelState: string; pairingState?: string; transportType?: string }; deviceProperties: { name: string }; hardwareProperties: { productType: string; platform?: string } }) => ({
identifier: d.identifier,
state: d.connectionProperties?.tunnelState ?? 'unknown',
name: d.deviceProperties?.name ?? 'unknown',
model: d.hardwareProperties?.productType ?? 'unknown',
platform: d.hardwareProperties?.platform ?? 'unknown',
transport: d.connectionProperties?.transportType ?? '',
paired: d.connectionProperties?.pairingState === 'paired',
}));
} catch {
return [];
} finally {
try { unlinkSync(tmp); } catch { /* ignore */ }
}
}
function isAvailableIPhone(device: DeviceListEntry): boolean {
const state = device.state.trim().toLowerCase();
const available = state === 'connected'
|| state.startsWith('available')
|| (state === 'disconnected' && device.transport.trim().toLowerCase() === 'wired');
return available
&& device.paired
&& device.platform.toLowerCase() === 'ios'
&& device.model.toLowerCase().startsWith('iphone');
}
function isPaired(udid: string): boolean {
// devicectl device info processes returns a clean exit when paired.
const tmp = `/tmp/devicectl-info-${process.pid}-${Date.now()}.json`;
@@ -69,12 +107,146 @@ function isPaired(udid: string): boolean {
'-d', udid,
'--json-output', tmp,
], { stdio: 'pipe', timeout: 30_000 });
try { require('fs').unlinkSync(tmp); } catch { /* ignore */ }
try { unlinkSync(tmp); } catch { /* ignore */ }
// Pair-required errors surface on stderr with "must be paired" or
// CoreDeviceError 2. Treat any non-zero exit as not-paired.
return r.status === 0;
}
function requireDeployEnv(name: 'GSTACK_IOS_DEVELOPMENT_TEAM' | 'GSTACK_IOS_BUNDLE_ID'): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`${name} is required when GSTACK_IOS_DEVICE_DEPLOY=1`);
}
return value;
}
function runChecked(
command: string,
args: string[],
opts: { cwd?: string; timeout?: number } = {},
): string {
const result = spawnSync(command, args, {
cwd: opts.cwd,
env: process.env,
stdio: 'pipe',
timeout: opts.timeout ?? 60_000,
maxBuffer: 32 * 1024 * 1024,
});
const output = `${result.stdout?.toString() ?? ''}${result.stderr?.toString() ?? ''}`;
if (result.error || result.status !== 0) {
const tail = output.split('\n').slice(-120).join('\n');
throw new Error([
`${command} ${args.join(' ')} failed (${result.error?.message ?? `exit ${result.status}`})`,
tail,
].filter(Boolean).join('\n'));
}
return output;
}
async function daemonJson<T>(
baseURL: string,
path: string,
init: RequestInit = {},
): Promise<{ status: number; body: T; raw: string }> {
const response = await fetch(`${baseURL}${path}`, {
...init,
signal: AbortSignal.timeout(60_000),
});
const raw = await response.text();
let body: T;
try {
body = JSON.parse(raw) as T;
} catch {
throw new Error(`${init.method ?? 'GET'} ${path} returned non-JSON HTTP ${response.status}: ${raw.slice(0, 500)}`);
}
return { status: response.status, body, raw };
}
function findElement(elements: DeviceElement[], identifier: string): DeviceElement | undefined {
return elements.find((element) =>
element.identifier === identifier
&& (element.frame?.w ?? 0) > 0
&& (element.frame?.h ?? 0) > 0,
);
}
type DeviceElementPredicate = (element: DeviceElement) => boolean;
interface DeviceViewport {
w: number;
h: number;
}
function isInsideViewport(element: DeviceElement, viewport: DeviceViewport): boolean {
const frame = element.frame;
if (!frame || frame.w <= 0 || frame.h <= 0) return false;
const centerX = frame.x + frame.w / 2;
const centerY = frame.y + frame.h / 2;
return centerX >= 0 && centerX <= viewport.w && centerY >= 0 && centerY <= viewport.h;
}
async function readDeviceElements(baseURL: string): Promise<DeviceElement[]> {
const result = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
if (result.status !== 200 || !Array.isArray(result.body.elements)) {
throw new Error(`GET /elements failed with HTTP ${result.status}: ${result.raw.slice(0, 500)}`);
}
return result.body.elements;
}
async function waitForDeviceElement(
baseURL: string,
predicate: DeviceElementPredicate,
description: string,
options: {
condition?: DeviceElementPredicate;
tappableIn?: DeviceViewport;
timeoutMs?: number;
} = {},
): Promise<DeviceElement> {
const deadline = Date.now() + (options.timeoutMs ?? 10_000);
let lastElements: DeviceElement[] = [];
while (Date.now() < deadline) {
lastElements = await readDeviceElements(baseURL);
const match = lastElements.find((element) =>
predicate(element)
&& (options.condition?.(element) ?? true)
&& (!options.tappableIn || isInsideViewport(element, options.tappableIn)),
);
if (match) return match;
await new Promise((resolve) => setTimeout(resolve, 200));
}
const visible = lastElements
.filter((element) => element.identifier || element.label)
.slice(0, 80)
.map((element) => element.identifier ?? element.label)
.join(', ');
throw new Error(`timed out waiting for ${description}; last elements: ${visible}`);
}
async function tapDeviceElement(
baseURL: string,
sessionId: string,
element: DeviceElement,
): Promise<void> {
const frame = element.frame;
if (!frame) throw new Error('cannot tap an element without a frame');
const tapped = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/tap', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({
x: frame.x + frame.w / 2,
y: frame.y + frame.h / 2,
}),
});
if (tapped.status !== 200 || tapped.body.ok !== true) {
throw new Error(`tap failed with HTTP ${tapped.status}: ${tapped.raw.slice(0, 500)}`);
}
}
describeIfDevice('ios device path', () => {
test('devicectl lists at least one connected device', () => {
const devices = listDevices();
@@ -104,11 +276,9 @@ describeIfDevice('ios device path', () => {
expect(paired.length).toBeGreaterThan(0);
});
test('fixture Swift package compiles for iOS target', () => {
// Use xcrun --sdk iphoneos to get the iOS SDK path, then pass it through
// to swift build via SDKROOT. This validates that the Swift templates
// (StateServer, DebugBridgeManager, DebugOverlay) compile against the
// iOS SDK — catches UIKit/SwiftUI gating bugs that macOS-only builds miss.
test('fixture iOS SDK and UIKit compile guards are available', () => {
// This is an environment + source-guard preflight. The explicit deployment
// test below performs the real signed iOS xcodebuild before installation.
const sdkPath = spawnSync('xcrun', ['--sdk', 'iphoneos', '--show-sdk-path'], { stdio: 'pipe' });
if (sdkPath.status !== 0) {
console.error('iOS SDK not found. Install via Xcode.');
@@ -117,16 +287,8 @@ describeIfDevice('ios device path', () => {
const sdk = sdkPath.stdout.toString().trim();
expect(sdk).toContain('iPhoneOS');
// Build the DebugBridgeUI target specifically for iOS. We can't use
// `swift build --triple arm64-apple-ios` directly because SwiftPM
// doesn't ship an iOS toolchain out of the box. The xcodebuild path
// requires a project — skip if no .xcodeproj exists.
// Instead, verify the iOS-only code compiles by parsing the canImport
// guards: if the template's `#if canImport(UIKit)` is wrong, the macOS
// build would have failed in the swift-build invariant test. The iOS
// SDK path being present is sufficient signal that the toolchain is
// installed; the deeper iOS-target build belongs to xcodebuild + a real
// app target, which is the "deploy to device" path documented below.
// SwiftPM cannot directly cross-build this UIKit package with the standalone
// host command, so keep the static guard assertion honest and narrowly named.
const fs = require('fs') as typeof import('fs');
const overlay = fs.readFileSync(
join(FIXTURE_PATH, 'Sources/DebugBridgeUI/DebugOverlay.swift'),
@@ -137,26 +299,575 @@ describeIfDevice('ios device path', () => {
expect(overlay).toContain('#endif');
});
// Documented next step. Becomes a real test once we have:
// - test/fixtures/ios-qa/FixtureApp/FixtureApp.xcodeproj (or generated)
// - A signing certificate + provisioning profile on the test machine
// - GSTACK_IOS_DEVICE_DEPLOY=1 environment opt-in
//
// The flow would be:
// xcodebuild -scheme FixtureApp -destination 'platform=iOS,id=<UDID>' \
// -allowProvisioningUpdates build install
// xcrun devicectl device process launch -d <UDID> --console <bundle-id>
// # Scrape boot token from os_log
// curl http://[<corodevice-ipv6>]:9999/healthz
// # ... full smoke loop ...
test.skip('TODO(deploy): build + deploy fixture to device + smoke test full StateServer loop', () => {});
});
describe('ios device deployment (explicit opt-in)', () => {
testIfDeploy('generates, signs, installs, launches, and drives the fixture through the daemon', async () => {
const developmentTeam = requireDeployEnv('GSTACK_IOS_DEVELOPMENT_TEAM');
const bundleId = requireDeployEnv('GSTACK_IOS_BUNDLE_ID');
const devices = listDevices();
const device = devices.find((candidate) => isAvailableIPhone(candidate) && isPaired(candidate.identifier));
if (!device) {
const summary = devices.length > 0
? devices.map((d) => ` ${d.name} (${d.model}, ${d.platform}, ${d.identifier}): state=${d.state}, paired=${d.paired}`).join('\n')
: ' devicectl returned no devices';
throw new Error([
'GSTACK_IOS_DEVICE_DEPLOY=1 requires an available, paired iPhone; stale unavailable devices are never selected.',
summary,
].join('\n'));
}
const workDir = mkdtempSync(join(tmpdir(), 'gstack-ios-device-deploy-'));
const fixtureDir = join(workDir, 'FixtureApp');
const derivedData = join(workDir, 'DerivedData');
let daemon: RunningDaemon | undefined;
let keepalive: { stop: () => void } | undefined;
let sessionId: string | undefined;
try {
cpSync(FIXTURE_PATH, fixtureDir, { recursive: true });
// Exercise the same deterministic bootstrap that /ios-qa and /ios-sync
// install for users. This creates the app-owned typed accessor before
// XcodeGen discovers the fixture sources.
runChecked(join(ROOT, 'bin/gstack-ios-qa-regen'), [
'--app-source', join(fixtureDir, 'Sources/FixtureApp'),
'--bridge-dir', fixtureDir,
], { cwd: fixtureDir });
const generatedAccessor = join(
fixtureDir,
'Sources/FixtureApp/DebugBridgeGenerated/StateAccessor.swift',
);
expect(existsSync(generatedAccessor)).toBe(true);
expect(readFileSync(generatedAccessor, 'utf8')).toContain('enum FixtureAppStateAccessor');
runChecked('xcodegen', [
'generate',
'--spec', join(fixtureDir, 'project.yml'),
'--project', fixtureDir,
'--project-root', fixtureDir,
], { cwd: fixtureDir });
const projectPath = join(fixtureDir, 'FixtureApp.xcodeproj');
expect(existsSync(projectPath)).toBe(true);
runChecked('xcodebuild', [
'-project', projectPath,
'-scheme', 'FixtureApp',
'-configuration', 'Debug',
'-destination', `platform=iOS,id=${device.identifier}`,
'-derivedDataPath', derivedData,
'-allowProvisioningUpdates',
`DEVELOPMENT_TEAM=${developmentTeam}`,
`PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`,
'CODE_SIGN_STYLE=Automatic',
'build',
], { cwd: fixtureDir, timeout: 300_000 });
const appBundle = join(derivedData, 'Build/Products/Debug-iphoneos/FixtureApp.app');
expect(existsSync(appBundle)).toBe(true);
const builtBundleId = runChecked('/usr/libexec/PlistBuddy', [
'-c', 'Print :CFBundleIdentifier',
join(appBundle, 'Info.plist'),
]).trim();
expect(builtBundleId).toBe(bundleId);
runChecked('xcrun', [
'devicectl', 'device', 'install', 'app',
'--device', device.identifier,
appBundle,
], { timeout: 120_000 });
runChecked('xcrun', [
'devicectl', 'device', 'process', 'launch',
'--device', device.identifier,
'--terminate-existing',
bundleId,
], { timeout: 60_000 });
keepalive = startTunnelKeepalive(device.identifier);
const bootstrap = await bootstrapTunnel({
udid: device.identifier,
bundleId,
startupTimeoutMs: 30_000,
});
if (!bootstrap.ok) {
throw new Error(`daemon tunnel bootstrap failed: ${bootstrap.error}${bootstrap.detail ? ` (${bootstrap.detail})` : ''}`);
}
// The first provider call consumes the already-rotated bootstrap. Later
// calls perform a fresh bootstrap so the same daemon can recover after
// this app is relaunched and its in-memory bearer changes.
let pendingTunnel: DeviceTunnel | undefined = bootstrap.tunnel;
let tunnelProviderCalls = 0;
const provideTunnel = async (): Promise<DeviceTunnel | null> => {
tunnelProviderCalls += 1;
if (pendingTunnel) {
const first = pendingTunnel;
pendingTunnel = undefined;
return first;
}
const refreshed = await bootstrapTunnel({
udid: device.identifier,
bundleId,
startupTimeoutMs: 30_000,
});
if (!refreshed.ok) {
throw new Error(`daemon rebootstrap failed: ${refreshed.error}${refreshed.detail ? ` (${refreshed.detail})` : ''}`);
}
return refreshed.tunnel;
};
const started = await startDaemon({
loopbackPort: 0,
tailnetEnabled: false,
pidfilePath: join(workDir, 'daemon.pid'),
tunnelProvider: provideTunnel,
});
if ('error' in started) {
throw new Error(`daemon failed to start: ${started.error}${started.reason ? ` (${started.reason})` : ''}`);
}
daemon = started;
const baseURL = `http://127.0.0.1:${daemon.loopbackPort}`;
const initialState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(initialState.status).toBe(200);
expect(typeof initialState.body._app_build_id).toBe('string');
expect(initialState.body._app_build_id).not.toBe('unknown');
expect(initialState.body._app_build_id).not.toBe('uninitialized');
expect(initialState.body._accessor_hash).toMatch(/^[a-f0-9]{64}$/);
expect(initialState.body._accessor_hash).not.toBe('uninitialized');
expect(Object.keys(initialState.body.keys ?? {}).sort()).toEqual([
'isLoggedIn',
'nickname',
'tapCounter',
'username',
]);
expect(initialState.body.keys?.nickname).toBeNull();
const screenshot = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
expect(screenshot.status).toBe(200);
expect(typeof screenshot.body.png_base64).toBe('string');
const png = Buffer.from(screenshot.body.png_base64!, 'base64');
expect(png.length).toBeGreaterThan(1_000);
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);
const requiredIdentifiers = [
'primary-button',
'toolbar-actions-menu',
'open-detail-button',
'tab-controls',
'tab-inputs',
'tab-rows',
];
let elementsBefore: DeviceElement[] = [];
let identifiers = new Set<string>();
const elementsDeadline = Date.now() + 10_000;
while (Date.now() < elementsDeadline) {
const before = await daemonJson<{ elements?: DeviceElement[] }>(baseURL, '/elements');
expect(before.status).toBe(200);
expect(Array.isArray(before.body.elements)).toBe(true);
elementsBefore = before.body.elements ?? [];
identifiers = new Set(
elementsBefore.map((element) => element.identifier).filter((value): value is string => Boolean(value)),
);
if (requiredIdentifiers.every((identifier) => identifiers.has(identifier))) break;
await new Promise((resolve) => setTimeout(resolve, 250));
}
expect(elementsBefore.length).toBeGreaterThan(30);
expect(requiredIdentifiers.filter((identifier) => !identifiers.has(identifier))).toEqual([]);
const appFrame = findElement(elementsBefore, 'fixture-tab-view')?.frame;
expect(appFrame).toBeDefined();
// Screenshot pixels and /tap coordinates must share UIKit's point
// space. A 3x PNG here recreates the original missed-tap bug.
expect(png.readUInt32BE(16)).toBe(appFrame!.w);
expect(png.readUInt32BE(20)).toBe(appFrame!.h);
const buttonBefore = findElement(elementsBefore, 'primary-button');
expect(buttonBefore).toBeDefined();
expect(typeof buttonBefore!.value).toBe('string');
const acquired = await daemonJson<{ session_id?: string }>(baseURL, '/session/acquire', { method: 'POST' });
expect(acquired.status).toBe(200);
expect(typeof acquired.body.session_id).toBe('string');
sessionId = acquired.body.session_id!;
const rejectedBooleanAsInteger = await daemonJson<{ error?: string }>(baseURL, '/state/tapCounter', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: true }),
});
expect(rejectedBooleanAsInteger.status).toBe(400);
expect(rejectedBooleanAsInteger.body.error).toBe('type_mismatch');
const rejectedIntegerAsBoolean = await daemonJson<{ error?: string }>(baseURL, '/state/isLoggedIn', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 1 }),
});
expect(rejectedIntegerAsBoolean.status).toBe(400);
expect(rejectedIntegerAsBoolean.body.error).toBe('type_mismatch');
const afterRejectedCoercions = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(afterRejectedCoercions.body.keys?.tapCounter).toBe(0);
expect(afterRejectedCoercions.body.keys?.isLoggedIn).toBe(false);
const wroteState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/tapCounter', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 7 }),
});
expect(wroteState.status).toBe(200);
expect(wroteState.body).toEqual({ ok: true });
const updatedState = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(updatedState.status).toBe(200);
expect(updatedState.body.keys?.tapCounter).toBe(7);
const wroteOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: 'Device' }),
});
expect(wroteOptional.status).toBe(200);
const optionalValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(optionalValue.body.keys?.nickname).toBe('Device');
const clearedOptional = await daemonJson<{ ok?: boolean }>(baseURL, '/state/nickname', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ value: null }),
});
expect(clearedOptional.status).toBe(200);
const clearedValue = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(clearedValue.body.keys?.nickname).toBeNull();
const restoredState = await daemonJson<{ ok?: boolean }>(baseURL, '/state/restore', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify(initialState.body),
});
expect(restoredState.status).toBe(200);
expect(restoredState.body).toEqual({ ok: true });
const afterRestore = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(afterRestore.body.keys?.tapCounter).toBe(0);
expect(afterRestore.body.keys?.nickname).toBeNull();
const viewport = { w: appFrame!.w, h: appFrame!.h };
const byIdentifier = (identifier: string): DeviceElementPredicate =>
(element) => element.identifier === identifier;
const byLabel = (label: string): DeviceElementPredicate =>
(element) => element.label?.trim() === label;
const tapAndWaitForValueChange = async (
target: DeviceElementPredicate,
oracle: DeviceElementPredicate,
description: string,
): Promise<DeviceElement> => {
const before = await waitForDeviceElement(
baseURL,
oracle,
`${description} oracle before tap`,
{ condition: (element) => typeof element.value === 'string' },
);
const targetElement = await waitForDeviceElement(
baseURL,
target,
`${description} target`,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId!, targetElement);
const after = await waitForDeviceElement(
baseURL,
oracle,
`${description} value change`,
{ condition: (element) => typeof element.value === 'string' && element.value !== before.value },
);
expect(after.value).not.toBe(before.value);
return after;
};
const firstInteger = (value: string | undefined): number | undefined => {
const match = value?.match(/-?\d+/);
return match ? Number(match[0]) : undefined;
};
const tapAndWaitForCountIncrement = async (
target: DeviceElementPredicate,
oracle: DeviceElementPredicate,
description: string,
): Promise<DeviceElement> => {
const before = await waitForDeviceElement(
baseURL,
oracle,
`${description} counter before tap`,
{ condition: (element) => firstInteger(element.value) !== undefined },
);
const beforeCount = firstInteger(before.value)!;
const targetElement = await waitForDeviceElement(
baseURL,
target,
`${description} target`,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId!, targetElement);
const after = await waitForDeviceElement(
baseURL,
oracle,
`${description} exactly-once counter increment`,
{ condition: (element) => firstInteger(element.value) === beforeCount + 1 },
);
expect(firstInteger(after.value)).toBe(beforeCount + 1);
return after;
};
// SwiftUI button styles and both navigation-bar controls.
for (const identifier of [
'primary-button',
'bordered-button',
'plain-button',
'destructive-button',
'nav-refresh-button',
]) {
await tapAndWaitForCountIncrement(
byIdentifier(identifier),
byIdentifier(identifier),
identifier,
);
}
// Menu presentation and both menu commands. The menu's own value is the
// stable oracle after each transient command element disappears.
for (const commandIdentifier of ['menu-add-item', 'menu-archive-item']) {
const menuBefore = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
'toolbar menu value',
{ condition: (element) => typeof element.value === 'string' },
);
const menu = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
'toolbar actions menu',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, menu);
const command = await waitForDeviceElement(
baseURL,
byIdentifier(commandIdentifier),
commandIdentifier,
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, command);
const beforeCounts = [...(menuBefore.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
expect(beforeCounts).toHaveLength(2);
const changedIndex = commandIdentifier === 'menu-add-item' ? 0 : 1;
const expectedCounts = beforeCounts.map((count, index) => count + (index === changedIndex ? 1 : 0));
const menuAfter = await waitForDeviceElement(
baseURL,
byIdentifier('toolbar-actions-menu'),
`${commandIdentifier} exactly-once result`,
{
condition: (element) => {
const counts = [...(element.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
return counts.length === 2 && counts.every((count, index) => count === expectedCounts[index]);
},
},
);
const afterCounts = [...(menuAfter.value?.matchAll(/\d+/g) ?? [])].map((match) => Number(match[0]));
expect(afterCounts).toEqual(expectedCounts);
}
// Push, interact with, and pop the explicit navigation destination.
const openDetail = await waitForDeviceElement(
baseURL,
byIdentifier('open-detail-button'),
'open detail button',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, openDetail);
await waitForDeviceElement(baseURL, byIdentifier('detail-screen-title'), 'detail destination');
await tapAndWaitForCountIncrement(
byIdentifier('detail-action-button'),
byIdentifier('detail-action-button'),
'detail action button',
);
const back = await waitForDeviceElement(
baseURL,
byIdentifier('detail-back-button'),
'detail back button',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, back);
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'controls after detail pop');
// Tab navigation plus native toggle, stepper, segmented picker, text
// input/commit, and a UIKit UIButton.
const inputsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-inputs'),
'Inputs tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, inputsTab);
await waitForDeviceElement(baseURL, byIdentifier('harness-toggle'), 'Inputs controls');
await tapAndWaitForValueChange(
byIdentifier('harness-toggle'),
byIdentifier('harness-toggle'),
'toggle',
);
await tapAndWaitForCountIncrement(
byIdentifier('harness-stepper-Increment'),
byIdentifier('harness-stepper'),
'stepper increment',
);
const segmentTwo = await waitForDeviceElement(
baseURL,
byLabel('Two'),
'segmented picker option Two',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, segmentTwo);
const selectedTwo = await waitForDeviceElement(
baseURL,
byIdentifier('harness-segmented-picker'),
'segmented picker selection',
{ condition: (element) => element.value?.includes('Two') === true },
);
expect(selectedTwo.value).toContain('Two');
const textField = await waitForDeviceElement(
baseURL,
byIdentifier('harness-text-field'),
'text field',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, textField);
const typed = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/type', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ text: 'device matrix' }),
});
expect(typed.status).toBe(200);
expect(typed.body).toMatchObject({ op: 'type', ok: true });
await waitForDeviceElement(
baseURL,
byIdentifier('harness-text-field'),
'typed text value',
{ condition: (element) => element.value === 'device matrix' },
);
await tapAndWaitForCountIncrement(
byIdentifier('commit-text-button'),
byIdentifier('commit-text-button'),
'text commit button',
);
// Commit clears FocusState; let the keyboard dismissal animation finish
// before choosing a scroll-view hit point for the UIKit control below.
await new Promise((resolve) => setTimeout(resolve, 500));
const scrollInputs = await daemonJson<{ ok?: boolean; op?: string }>(baseURL, '/swipe', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-session-id': sessionId,
},
body: JSON.stringify({ from_x: 200, from_y: 650, to_x: 200, to_y: 220 }),
});
expect(scrollInputs.status).toBe(200);
expect(scrollInputs.body).toMatchObject({ op: 'swipe', ok: true });
await tapAndWaitForCountIncrement(
byIdentifier('uikit-button'),
byIdentifier('uikit-button'),
'UIKit button',
);
// All four list rows and the row navigation-bar action.
const rowsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-rows'),
'Rows tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, rowsTab);
await waitForDeviceElement(baseURL, byIdentifier('row-alpha-button'), 'Rows list');
for (const row of ['alpha', 'bravo', 'charlie', 'delta']) {
await tapAndWaitForCountIncrement(
byIdentifier(`row-${row}-button`),
byIdentifier(`row-${row}-button`),
`${row} row`,
);
}
await tapAndWaitForCountIncrement(
byIdentifier('rows-toolbar-button'),
byIdentifier('rows-toolbar-button'),
'rows toolbar button',
);
const controlsTab = await waitForDeviceElement(
baseURL,
byIdentifier('tab-controls'),
'Controls tab',
{ tappableIn: viewport },
);
await tapDeviceElement(baseURL, sessionId, controlsTab);
await waitForDeviceElement(baseURL, byIdentifier('primary-button'), 'Controls tab restored');
// Keep the daemon alive while the app process gets a new boot token.
// The first proxied request must observe the stale bearer, invalidate
// only that tunnel, single-flight a fresh bootstrap, and retry once.
runChecked('xcrun', [
'devicectl', 'device', 'process', 'launch',
'--device', device.identifier,
'--terminate-existing',
bundleId,
], { timeout: 60_000 });
await new Promise((resolve) => setTimeout(resolve, 500));
const afterRelaunch = await daemonJson<{ png_base64?: string; error?: string }>(baseURL, '/screenshot');
expect(afterRelaunch.status).toBe(200);
expect(Buffer.from(afterRelaunch.body.png_base64 ?? '', 'base64').length).toBeGreaterThan(1_000);
expect(tunnelProviderCalls).toBe(2);
const stateAfterRelaunch = await daemonJson<StateSnapshot>(baseURL, '/state/snapshot');
expect(stateAfterRelaunch.status).toBe(200);
expect(stateAfterRelaunch.body._accessor_hash).toBe(initialState.body._accessor_hash);
} finally {
if (daemon && sessionId) {
await daemonJson(`http://127.0.0.1:${daemon.loopbackPort}`, '/session/release', { method: 'POST' }).catch(() => undefined);
}
if (daemon) await daemon.close();
keepalive?.stop();
rmSync(workDir, { recursive: true, force: true });
}
}, 600_000);
});
// Always-on instructions if not paired. Surfaces actionable steps even when
// the test is opted in via env var but the device isn't ready.
if (HAS_DEVICE) {
const devices = listDevices();
const unpaired = devices.filter(d => !isPaired(d.identifier));
const unpaired = devices.filter(d =>
d.platform.toLowerCase() === 'ios'
&& d.model.toLowerCase().startsWith('iphone')
&& !d.paired,
);
if (unpaired.length > 0) {
console.error('');
console.error('=== iOS DEVICE PAIRING REQUIRED ===');
+257 -21
View File
@@ -19,38 +19,90 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { existsSync, readFileSync } from 'fs';
import { 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');
const GEN_ACCESSORS_PACKAGE = join(ROOT, 'ios-qa/scripts/gen-accessors-tool/Package.swift');
// 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.
const COPIED_BRIDGE_TEMPLATES = [
['StateServer.swift.template', 'Sources/DebugBridgeCore/StateServer.swift'],
['DebugBridgeManager.swift.template', 'Sources/DebugBridgeCore/DebugBridgeManager.swift'],
['DebugOverlay.swift.template', 'Sources/DebugBridgeUI/DebugOverlay.swift'],
['Bridges.swift.template', 'Sources/DebugBridgeUI/Bridges.swift'],
['DebugBridgeTouch.h.template', 'Sources/DebugBridgeTouch/include/DebugBridgeTouch.h'],
['DebugBridgeTouch.m.template', 'Sources/DebugBridgeTouch/DebugBridgeTouch.m'],
] as const;
function readTemplate(name: string): string {
return readFileSync(join(TEMPLATES_PATH, name), 'utf-8');
}
function normalizeBridgePackage(source: string): string {
// Package.swift.template has a generated-file prologue while the fixture has
// a fixture-specific one. The tools-version declaration must remain first in
// both real files, but neither header is part of the copied bridge surface.
const importOffset = source.indexOf('import PackageDescription');
expect(importOffset).toBeGreaterThanOrEqual(0);
let packageBody = source.slice(importOffset);
// The fixture deliberately has its own package identity and XCTest target.
// Normalize only those fixture concerns; all three bridge products, targets,
// dependencies, settings, and paths must otherwise stay in lockstep.
packageBody = packageBody.replace(
/(let package = Package\(\s*name:)\s*"[^"]+"/,
'$1 "<bridge-package>"',
);
packageBody = packageBody.replace(
/\n\s*\.testTarget\(\s*\n\s*name:\s*"DebugBridgeCoreTests",[\s\S]*?\n\s{8}\),?/,
'',
);
// Ignore prose and formatting so a template-only explanatory comment does
// not conceal a meaningful manifest mismatch.
return packageBody
.replace(/\/\/.*$/gm, '')
.replace(/\s+/g, '')
.replace(/,([\])])/g, '$1');
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function bracedBlock(source: string, openBraceOffset: number): string {
let depth = 0;
for (let offset = openBraceOffset; offset < source.length; offset++) {
if (source[offset] === '{') depth++;
if (source[offset] !== '}') continue;
depth--;
if (depth === 0) return source.slice(openBraceOffset, offset + 1);
}
return '';
}
// The fixture is where the bridge is compiled and exercised end-to-end. Every
// source copied into consuming apps must therefore be the canonical template,
// or device QA can pass against code that /ios-qa never installs.
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);
});
for (const [templateName, fixtureDestination] of COPIED_BRIDGE_TEMPLATES) {
test(`${templateName} matches ${fixtureDestination}`, () => {
expect(readTemplate(templateName)).toBe(
readFileSync(join(FIXTURE_PATH, fixtureDestination), 'utf-8'),
);
});
}
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 bridge declarations match after fixture-only normalization', () => {
const template = readTemplate('Package.swift.template');
const fixture = readFileSync(join(FIXTURE_PATH, 'Package.swift'), 'utf-8');
expect(normalizeBridgePackage(template)).toBe(normalizeBridgePackage(fixture));
});
test('Package.swift.template declares all 3 DebugBridge targets', () => {
const tmpl = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
const tmpl = readTemplate('Package.swift.template');
// 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}"`);
@@ -59,6 +111,190 @@ describe('template ↔ fixture parity', () => {
// app gets the transitive set with one dependency entry.
expect(tmpl).toMatch(/name:\s*"DebugBridgeUI"[\s\S]*?dependencies:\s*\["DebugBridgeCore",\s*"DebugBridgeTouch"\]/);
});
test('generated Swift packages only reference shipped test directories', () => {
const genAccessorsPackage = readFileSync(GEN_ACCESSORS_PACKAGE, 'utf-8');
const debugBridgePackage = readFileSync(join(TEMPLATES_PATH, 'Package.swift.template'), 'utf-8');
expect(genAccessorsPackage).not.toContain('Tests/GenAccessorsTests');
expect(debugBridgePackage).not.toContain('Tests/DebugBridgeCoreTests');
});
test('Package.swift.template keeps swift-tools-version on the first line', () => {
const tmpl = readTemplate('Package.swift.template');
expect(tmpl.split(/\r?\n/, 1)[0]).toBe('// swift-tools-version:5.9');
});
});
describe('iOS tap harness regressions', () => {
test('manager receives app-owned generated accessors instead of a no-op package stub', () => {
const manager = readTemplate('DebugBridgeManager.swift.template');
const wiring = readTemplate('DebugBridgeWiring.swift.template');
const fixtureApp = readFileSync(
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppApp.swift'),
'utf-8',
);
expect(manager).toContain('func start<State>');
expect(manager).toContain('register: (State) -> Void');
expect(manager).toContain('register(appState)');
expect(manager).not.toContain('public enum AppStateAccessor');
expect(manager).not.toContain('protocol AppState');
expect(wiring).toContain('import DebugBridgeCore');
expect(wiring).toContain('import DebugBridgeUI');
expect(wiring).toContain('DebugBridgeUIWiring.installAll()');
expect(wiring.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
wiring.indexOf('DebugBridgeManager.shared.start'),
);
expect(fixtureApp.indexOf('DebugBridgeUIWiring.installAll()')).toBeLessThan(
fixtureApp.indexOf('DebugBridgeManager.shared.start'),
);
expect(wiring).not.toContain('import DebugBridge\n');
expect(wiring).not.toContain('AccessibilityScanner');
expect(wiring).not.toContain('MutationDispatcher');
});
test('fixture uses an @Observable-compatible source marker, not a property wrapper', () => {
const state = readFileSync(
join(FIXTURE_PATH, 'Sources/FixtureApp/FixtureAppState.swift'),
'utf-8',
);
expect(state).toContain('@Observable');
expect(state.match(/\/\/ @Snapshotable/g)?.length).toBe(4);
expect(state).not.toContain('@propertyWrapper');
expect(state).not.toMatch(/^[\t ]*@Snapshotable[\t ]+(?:public[\t ]+)?var/m);
});
test('recurses through iOS automation elements to expose nested SwiftUI controls', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toContain('element.automationElements');
expect(bridges).toContain('debugBridgeAccessibilityChildren(of: element)');
expect(bridges).toContain('visited.insert(ObjectIdentifier(element)).inserted');
expect(bridges).toContain('var remaining = 2_048');
});
test('enables accessibility automation before SwiftUI AX is installed', () => {
const implementation = readTemplate('DebugBridgeTouch.m.template');
const bridges = readTemplate('Bridges.swift.template');
const helper = [...implementation.matchAll(
/static\s+void\s+([A-Za-z_]\w*)\s*\(\s*void\s*\)\s*\{/g,
)].find((candidate) => {
const body = bracedBlock(
implementation,
candidate.index! + candidate[0].lastIndexOf('{'),
);
return body.includes('_AXSAutomationEnabled') && body.includes('_AXSSetAutomationEnabled');
});
expect(helper).toBeDefined();
const helperName = helper![1];
const helperBody = bracedBlock(
implementation,
helper!.index! + helper![0].lastIndexOf('{'),
);
expect(helperBody).toContain('_AXSAutomationEnabled');
expect(helperBody).toContain('_AXSSetAutomationEnabled');
// Accept either Objective-C's eager +load hook or an explicit public
// bootstrap selector, but require the enabling helper to be called before
// Swift installs the resolver that walks SwiftUI's accessibility tree.
const bootstrap = [...implementation.matchAll(/\+\s*\(void\)\s*([A-Za-z_]\w*)\s*\{/g)]
.find((candidate) => bracedBlock(
implementation,
candidate.index! + candidate[0].lastIndexOf('{'),
).match(new RegExp(`\\b${escapeRegExp(helperName)}\\s*\\(`)));
expect(bootstrap).toBeDefined();
if (bootstrap![1] === 'load') {
expect(bootstrap!.index!).toBeLessThan(implementation.indexOf('+ (BOOL)sendTapAtPoint:'));
} else {
const bootstrapCall = bridges.search(
new RegExp(`DebugBridgeTouch\\.${escapeRegExp(bootstrap![1])}\\s*\\(`),
);
expect(bootstrapCall).toBeGreaterThanOrEqual(0);
expect(bootstrapCall).toBeLessThan(bridges.indexOf('ElementsBridge.resolver'));
}
});
test('renders screenshots at one pixel per window point', () => {
const bridges = readTemplate('Bridges.swift.template');
const declaration = bridges.match(
/(?:let|var)\s+([A-Za-z_]\w*)\s*=\s*UIGraphicsImageRendererFormat(?:\.default)?\(\)/,
);
expect(declaration).not.toBeNull();
const formatName = declaration![1];
const scalePattern = new RegExp(`\\b${escapeRegExp(formatName)}\\.scale\\s*=\\s*1(?:\\.0)?\\b`);
const rendererPattern = new RegExp(
`UIGraphicsImageRenderer\\(\\s*bounds:\\s*bounds,\\s*format:\\s*${escapeRegExp(formatName)}\\s*\\)`,
);
const declarationOffset = declaration!.index!;
const scaleOffset = bridges.search(scalePattern);
const rendererOffset = bridges.search(rendererPattern);
expect(scaleOffset).toBeGreaterThan(declarationOffset);
expect(rendererOffset).toBeGreaterThan(scaleOffset);
});
test('uses accessibilityActivate for SwiftUI while retaining synthesized-touch delivery', () => {
const bridges = readTemplate('Bridges.swift.template');
const tapStart = bridges.indexOf('private static func handleTap');
const tapEnd = bridges.indexOf('private static func handleType', tapStart);
expect(tapStart).toBeGreaterThanOrEqual(0);
expect(tapEnd).toBeGreaterThan(tapStart);
const handleTap = bridges.slice(tapStart, tapEnd);
const synthesizedTouchOffset = handleTap.indexOf('DebugBridgeTouch.sendTap');
const fallbackCall = handleTap.match(
/\b([A-Za-z_]\w*)\s*\(\s*at:\s*point\s*,\s*in:\s*window\s*\)/,
);
const activationOffset = handleTap.indexOf('.accessibilityActivate()');
expect(synthesizedTouchOffset).toBeGreaterThanOrEqual(0);
expect(fallbackCall).not.toBeNull();
expect(activationOffset).toBeGreaterThan(fallbackCall!.index!);
const fallbackName = fallbackCall![1];
expect(bridges).toMatch(
new RegExp(`(?:private\\s+)?static\\s+func\\s+${escapeRegExp(fallbackName)}\\s*\\(`),
);
});
test('finishes programmatic scrolls before returning success to the next tap', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toContain('setContentOffset(off, animated: false)');
expect(bridges).not.toContain('setContentOffset(off, animated: true)');
});
test('serializes accessibility traits without signed Int truncation', () => {
const bridges = readTemplate('Bridges.swift.template');
expect(bridges).toMatch(/\btraits\s*:\s*UInt64\b/);
expect(bridges).toContain('.uint64Value');
expect(bridges).not.toMatch(/\bInt(?:64)?\s*\(\s*view\.accessibilityTraits\.rawValue\s*\)/);
expect(bridges).not.toMatch(/accessibilityTraits[\s\S]{0,120}?\.intValue\b/);
});
test('validates every generated model before applying any snapshot state', () => {
const server = readTemplate('StateServer.swift.template');
const validationLoop = server.indexOf('for restore in atomicRestores');
const applyComment = server.indexOf('Phase two applies only after every model accepted');
const applyLoop = server.indexOf('for restore in atomicRestores', validationLoop + 1);
expect(server).toContain('typealias AtomicRestoreFn = (JSONDict, Bool) -> RestoreResult');
expect(server).toContain('restore(keys, false)');
expect(server).toContain('restore(keys, true)');
expect(validationLoop).toBeGreaterThanOrEqual(0);
expect(applyComment).toBeGreaterThan(validationLoop);
expect(applyLoop).toBeGreaterThan(applyComment);
});
test('turns non-JSON response bodies into an explicit HTTP 500', () => {
const server = readTemplate('StateServer.swift.template');
expect(server).toContain('JSONSerialization.isValidJSONObject(body)');
expect(server).toContain('responseStatus = 500');
expect(server).toContain('response_not_json_serializable');
expect(server).not.toContain('?? Data("{}".utf8)');
});
});
function hasSwift(): boolean {
+8 -4
View File
@@ -171,9 +171,12 @@ describe('ios-qa E2E (no-device path)', () => {
writeFileSync(join(srcDir, 'AppState.swift'), `
@Observable
class AppState {
@Snapshotable var isLoggedIn: Bool = false
@Snapshotable var username: String = ""
@Snapshotable var counter: Int = 0
// @Snapshotable
var isLoggedIn: Bool = false
// @Snapshotable
var username: String = ""
// @Snapshotable
var counter: Int = 0
var ephemeralCache: [String: Any] = [:]
}
`);
@@ -189,7 +192,8 @@ class AppState {
expect(result.specs).toHaveLength(1);
expect(result.specs[0]!.fields.map(f => f.name).sort()).toEqual(['counter', 'isLoggedIn', 'username']);
const generatedSwift = readFileSync(result.outputPath, 'utf-8');
expect(generatedSwift).toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('enum AppStateAccessor');
expect(generatedSwift).not.toContain('public enum AppStateAccessor');
expect(generatedSwift).toContain('key: "isLoggedIn"');
expect(generatedSwift).toContain('key: "counter"');
expect(generatedSwift).not.toContain('key: "ephemeralCache"'); // not marked @Snapshotable
+10 -4
View File
@@ -8,7 +8,7 @@
* 3. sha8($(git config user.email))
* 4. anonymous-<sha8(hostname)>
*
* Result is persisted under user_slug_at_<endpoint-hash> for stability.
* Result is persisted under user_slug_at_<endpoint-id> for stability.
* Test isolation via GSTACK_HOME and HOME env overrides.
*
* Gate-tier, free, ~50ms.
@@ -87,12 +87,12 @@ describe('resolve-user-slug fallback chain', () => {
expect(slug).toMatch(/^(email-|anonymous-)[a-f0-9]+$|^[a-zA-Z0-9-]+$/);
});
test('persists resolution to user_slug_at_<hash> on first call', () => {
test('persists resolution to user_slug_at_<endpoint-id> on first call', () => {
runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'persisttest' });
const configFile = join(TMP_HOME, 'config.yaml');
expect(existsSync(configFile)).toBe(true);
const content = readFileSync(configFile, 'utf-8');
expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m);
expect(content).toMatch(/^user_slug_at_(local|[a-f0-9]{8}|[a-f0-9]{16}):\s+persisttest/m);
});
test('subsequent calls return same slug (stable across sessions)', () => {
@@ -104,7 +104,7 @@ describe('resolve-user-slug fallback chain', () => {
});
});
describe('brain_trust_policy@<hash> namespace', () => {
describe('brain_trust_policy@<endpoint-id> namespace', () => {
test('default value is "unset"', () => {
const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0);
@@ -158,4 +158,10 @@ describe('key validation', () => {
const result = runConfig(['get', 'brain_trust_policy@abc123ff'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0);
});
test('accepts @local suffix on key', () => {
const result = runConfig(['get', 'brain_trust_policy@local'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0);
expect(result.stdout).toBe('unset');
});
});