Skip to content

Commit 94cf7fd

Browse files
fix(ios): keep foldable classification on the simulator set source
1 parent 20841c0 commit 94cf7fd

8 files changed

Lines changed: 158 additions & 72 deletions

File tree

‎.changeset/foldable-scoped-rotation.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22
'agent-device': patch
33
---
44

5-
Fix foldable iPhone simulator rotation in scoped sets and confirm the lit panel in both default and scoped sets. Both routes use the fold helper, with no CoreDevice orientation polling. An already visible target dispatches nothing. Incomplete, unknown-power and multi-lit panel tables neither confirm nor dispatch. A target the panel never shows fails with `orientation-unverified`.
5+
Fix foldable iPhone simulator rotation in scoped sets and confirm the lit panel in both default and scoped sets. Rotation in both sets uses the fold helper, with no CoreDevice orientation polling. An already visible target dispatches nothing. Incomplete, unknown-power and multi-lit panel tables neither confirm nor dispatch. A target the panel never shows fails with `orientation-unverified`.
66

77
Use the fixed iPhone Duo picker offsets measured in both sets (outer 0 degrees, inner +90 degrees), replacing runtime calibration. Inner landscape-left sends `portrait-upside-down`, mapped to the HID wire value `pud`; it remains unsupported as a user target. Delayed reads wait without resending the same picker value to the same panel.

‎docs/adr/0025-foldable-apple-panels.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ It is the only reader of panel tables, and it selects the source.
5858
For a simulator in a scoped set, the fold helper's `panels` mode is the screen authority.
5959
It emits the same entry shape from the guest sources that `dtdeviceinfod` relays to CoreDevice: `CADisplay` and `BLSBacklight`.
6060
The guest lists every integrated panel and omits only the facts it cannot read.
61-
Foldable `fold` and `orientation` operations use the guest table in either simulator set.
61+
Fold pose and rotation lit-panel reads explicitly request the guest table in either simulator set.
62+
Panel-count classification keeps the set-based source: CoreDevice in the default set, guest in a scoped set.
6263
Rotation confirms the lit panel because CoreDevice orientation can match the picker while the visible panel shows another orientation.
6364
- **Completeness.**
6465
Both sources list one entry per integrated panel. A panel that the parser drops is not single-panel evidence.
@@ -374,7 +375,9 @@ Rejected alternatives:
374375
`orientation` therefore rotates a multi-panel simulator through the guest device-state control: the HID event that Device Hub's orientation menu sends (`orientation-picker-control`, type `enum`).
375376
The picker accepts `pud` for upside-down dispatch, which the inner panel needs to show landscape-left.
376377
The user target `portrait-upside-down` remains refused with `unsupported-foldable-orientation`.
377-
Routing uses the `fold` admission and the panel table. A resolved panel count is a hardware fact, so the daemon probes each simulator once.
378+
Routing uses the `fold` admission and the set-based panel-count source. A resolved panel count is a hardware fact, so the daemon probes each simulator once.
379+
A supplied helper is a reuse handle; only an explicit guest-source request changes the default-set source.
380+
Thus, classifying a single-panel default-set simulator does not prepare the fold helper.
378381

379382
Verification was updated on 2026-11-12 under [#4440](https://github.com/callstack/agent-device/pull/4440).
380383
In both simulator sets, the helper reads the guest panel table and dispatches the fixed picker value for the established lit panel.

‎packages/platform-apple/src/core/__tests__/display-inventory.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,7 @@ describe('scoped simulator panel table', () => {
765765
readPanelPayload,
766766
};
767767

768-
const inventory = await queryAppleDisplayInventory(device, { foldHelper });
768+
const inventory = await queryAppleDisplayInventory(device, { source: 'guest', foldHelper });
769769

770770
assert.equal(inventory.activeDisplay?.name, 'LCD-1');
771771
assert.equal(readPanelPayload.mock.calls.length, 1);
@@ -774,6 +774,26 @@ describe('scoped simulator panel table', () => {
774774
assert.equal(mockRunXcrun.mock.calls.length, 0);
775775
});
776776

777+
test('a helper handle alone keeps the default-set CoreDevice source', async () => {
778+
mockReadHostTextFile.mockResolvedValue(JSON.stringify(IPHONE_DUO_CLOSED));
779+
const readPanelPayload = vi.fn(async () => {
780+
throw new Error('guest must not classify');
781+
});
782+
const foldHelper = {
783+
setPose: vi.fn(),
784+
rotate: vi.fn(),
785+
readHingeAngle: vi.fn(),
786+
readPanelPayload,
787+
};
788+
const inventory = await queryAppleDisplayInventory(
789+
{ ...scopedDuo, simulatorSetPath: undefined },
790+
{ foldHelper },
791+
);
792+
assert.equal(inventory.multiScreen, true);
793+
assert.equal(readPanelPayload.mock.calls.length, 0);
794+
assert.equal(mockRunXcrun.mock.calls.length, 1);
795+
});
796+
777797
test('applies the same rule to a CoreDevice table whose second panel does not parse', async () => {
778798
const [outer, inner] = IPHONE_DUO_CLOSED.result.displays;
779799
const { displayId: _displayId, ...innerWithoutId } = inner!;

‎packages/platform-apple/src/core/display-inventory.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ type CoreDeviceDisplayPayload = {
9090
type AppleDisplayInventoryOptions = {
9191
timeoutMs?: number;
9292
signal?: AbortSignal;
93-
/** Selects the guest table in either simulator set and reuses this helper. */
93+
/** Requests the guest table for a lit-panel read in either simulator set. */
94+
source?: 'guest';
95+
/** Reuses a helper without changing the simulator set's table source. */
9496
foldHelper?: FoldHelper;
9597
};
9698

@@ -99,7 +101,7 @@ const DISPLAYS_UNSUPPORTED_HINT =
99101

100102
/**
101103
* Reads the CoreDevice display table for one Apple device, or the guest's equivalent for a
102-
* simulator in a scoped device set or a caller that supplies its fold helper.
104+
* simulator in a scoped device set or a caller that explicitly requests the guest table.
103105
*
104106
* Deliberately uncached: which panel is lit is exactly what changes when an
105107
* operator folds or opens the device, and a cached answer would resume capturing
@@ -114,7 +116,7 @@ export async function queryAppleDisplayInventory(
114116
options: AppleDisplayInventoryOptions,
115117
): Promise<AppleDisplayInventory> {
116118
const table =
117-
options.foldHelper || simulatorAddressFor(device).simulatorSetPath
119+
options.source === 'guest' || simulatorAddressFor(device).simulatorSetPath
118120
? await readGuestPanelTable(device, options)
119121
: await readCoreDevicePanelTable(device, options);
120122
if (!table.ok) {

‎packages/platform-apple/src/foldable/orientation.test.ts‎

Lines changed: 107 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import fs from 'node:fs';
12
import { beforeEach, describe, expect, test, vi } from 'vitest';
23
import { bindTestCommandHost, createLiveTestHostEnvironment } from '@agent-device/test-kit';
34

@@ -14,16 +15,18 @@ import { AppError } from '@agent-device/kernel/errors';
1415
import { runAppleRunnerCommand } from '../core/runner-client.ts';
1516
import { createLocalAppleToolProvider, withAppleToolProvider } from '../core/tool-provider.ts';
1617
import { createAppleInteractor } from '../interactor.ts';
17-
import { readFoldablePanelTable } from './orientation.ts';
18+
import { isAppleFoldableSimulator, readFoldablePanelTable } from './orientation.ts';
19+
import { ensureFoldHelperBinary } from './fold-helper-cache.ts';
1820
import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/device-fixtures.ts';
19-
import { IPHONE_17_SINGLE_PANEL } from '../__tests__/display-fixtures.ts';
21+
import { IPHONE_17_SINGLE_PANEL, IPHONE_DUO_CLOSED } from '../__tests__/display-fixtures.ts';
2022

2123
bindTestCommandHost();
2224

2325
const mockRunner = vi.mocked(runAppleRunnerCommand);
2426

2527
beforeEach(() => {
2628
mockRunner.mockClear();
29+
vi.mocked(ensureFoldHelperBinary).mockReset().mockResolvedValue({ path: '/cache/fold-helper' });
2730
});
2831

2932
/** A fresh simulator id per test: the daemon keeps each simulator's resolved panel count. */
@@ -38,11 +41,17 @@ const SINGLE_PANEL_DISPLAYS = IPHONE_17_SINGLE_PANEL.result.displays;
3841
/** Single-panel and physical targets keep their runner rotation. */
3942
function fakeSimulator(device: DeviceInfo, displays: readonly object[]) {
4043
const helpers: string[][] = [];
41-
const devicectl: string[] = [];
44+
const unexpectedTools: string[] = [];
45+
const classificationReads: string[] = [];
4246
const provider = createLocalAppleToolProvider({
4347
runCommand: async (_command, args) => {
48+
if (args.slice(0, 4).join(' ') === 'devicectl device info displays') {
49+
classificationReads.push(args.join(' '));
50+
fs.writeFileSync(args.at(-1)!, JSON.stringify({ result: { displays } }));
51+
return { stdout: '', stderr: '', exitCode: 0 };
52+
}
4453
if (args[0] !== 'simctl') {
45-
devicectl.push(args.join(' '));
54+
unexpectedTools.push(args.join(' '));
4655
throw new Error(`unexpected tool call: ${args.join(' ')}`);
4756
}
4857
const spawn = args.slice(args.indexOf('spawn'));
@@ -55,7 +64,8 @@ function fakeSimulator(device: DeviceInfo, displays: readonly object[]) {
5564
});
5665
return {
5766
helpers,
58-
devicectl,
67+
unexpectedTools,
68+
classificationReads,
5969
rotate: (rotation: DeviceRotation) =>
6070
withAppleToolProvider(provider, () =>
6171
createAppleInteractor(device, {}, createLiveTestHostEnvironment()).setOrientation(rotation),
@@ -68,26 +78,64 @@ test('a simulator is probed for its panel count once', async () => {
6878
await rotateDuo(sim, 'landscape-left');
6979
await rotateDuo(sim, 'portrait');
7080
expect(sim.panelReads()).toBe(5);
81+
expect(sim.classificationReads).toHaveLength(1);
7182
expect(sim.rotations()).toEqual(['landscape-left', 'portrait']);
72-
expect(sim.devicectl).toEqual([]);
83+
expect(sim.unexpectedTools).toEqual([]);
7384
});
7485

7586
test('a single-panel simulator keeps the runner rotation', async () => {
7687
const device = simulator();
7788
const sim = fakeSimulator(device, SINGLE_PANEL_DISPLAYS);
7889
await expect(sim.rotate('landscape-left')).resolves.toEqual({ orientation: 'landscape-left' });
79-
expect(sim.helpers).toEqual([['panels']]);
90+
expect(sim.helpers).toEqual([]);
91+
expect(ensureFoldHelperBinary).not.toHaveBeenCalled();
92+
expect(sim.classificationReads).toHaveLength(1);
8093
expect(mockRunner).toHaveBeenCalledWith(
8194
device,
8295
expect.objectContaining({ command: 'rotate', orientation: 'landscape-left' }),
8396
expect.anything(),
8497
);
8598
});
8699

100+
test('a cold default Duo classifies without preparing the guest helper', async () => {
101+
const device = simulator();
102+
const calls: string[] = [];
103+
vi.mocked(ensureFoldHelperBinary).mockRejectedValue(
104+
new Error('cold helper exceeds guest budget'),
105+
);
106+
const provider = createLocalAppleToolProvider({
107+
runCommand: async (_command, args) => {
108+
calls.push(args.join(' '));
109+
expect(args.slice(0, 4)).toEqual(['devicectl', 'device', 'info', 'displays']);
110+
fs.writeFileSync(args.at(-1)!, JSON.stringify(IPHONE_DUO_CLOSED));
111+
return { stdout: '', stderr: '', exitCode: 0 };
112+
},
113+
});
114+
await expect(
115+
withAppleToolProvider(provider, () => isAppleFoldableSimulator(device, undefined)),
116+
).resolves.toBe(true);
117+
expect(calls).toHaveLength(1);
118+
expect(ensureFoldHelperBinary).not.toHaveBeenCalled();
119+
});
120+
121+
test('a cold default Duo helper failure cannot fall back to runner success', async () => {
122+
const sim = fakeDuo(simulator(), { lit: 'outer', picker: 'portrait' });
123+
vi.mocked(ensureFoldHelperBinary).mockRejectedValue(
124+
new Error('cold helper exceeds guest budget'),
125+
);
126+
await expect(rotateDuo(sim, 'landscape-left')).resolves.toMatchObject({
127+
code: 'COMMAND_FAILED',
128+
details: { reason: 'orientation-unverified' },
129+
});
130+
expect(sim.classificationReads).toHaveLength(1);
131+
expect(mockRunner).not.toHaveBeenCalled();
132+
expect(sim.rotations()).toEqual([]);
133+
});
134+
87135
test('a physical device keeps the runner rotation without a CoreDevice probe', async () => {
88136
const sim = fakeSimulator(IOS_DEVICE, []);
89137
await expect(sim.rotate('landscape-left')).resolves.toEqual({ orientation: 'landscape-left' });
90-
expect(sim.devicectl).toEqual([]);
138+
expect(sim.unexpectedTools).toEqual([]);
91139
expect(mockRunner).toHaveBeenCalledTimes(1);
92140
});
93141

@@ -142,7 +190,8 @@ function fakeDuo(
142190
},
143191
) {
144192
const helpers: string[][] = [];
145-
const devicectl: string[] = [];
193+
const unexpectedTools: string[] = [];
194+
const classificationReads: string[] = [];
146195
const offsets = options.offsets ?? MEASURED_OFFSETS;
147196
let lit = options.lit;
148197
let pickerAngle = PANEL_ANGLE[options.picker];
@@ -175,30 +224,38 @@ function fakeDuo(
175224
type: { integrated: {} },
176225
};
177226
};
227+
const readPanels = () => {
228+
panelReads += 1;
229+
if (lagReadsLeft > 0) lagReadsLeft -= 1;
230+
else if (pendingPicker !== undefined) {
231+
pickerAngle = pendingPicker;
232+
pendingPicker = undefined;
233+
show(lit);
234+
}
235+
const bothLit =
236+
bothLitReads > 0 ||
237+
(options.bothLitFromRead !== undefined && panelReads >= options.bothLitFromRead);
238+
if (bothLitReads > 0) bothLitReads -= 1;
239+
return [panel('outer', bothLit), panel('inner', bothLit)];
240+
};
178241
const provider = createLocalAppleToolProvider({
179242
runCommand: async (_command, args) => {
180243
const [tool, ...rest] = args;
244+
if (args.slice(0, 4).join(' ') === 'devicectl device info displays') {
245+
classificationReads.push(args.join(' '));
246+
fs.writeFileSync(args.at(-1)!, JSON.stringify({ result: { displays: readPanels() } }));
247+
return { stdout: '', stderr: '', exitCode: 0 };
248+
}
181249
if (tool !== 'simctl') {
182-
devicectl.push(args.join(' '));
250+
unexpectedTools.push(args.join(' '));
183251
throw new Error(`unexpected tool call: ${args.join(' ')}`);
184252
}
185253
const spawn = rest.slice(rest.indexOf('spawn'));
186254
expect(spawn.slice(0, 3)).toEqual(['spawn', device.id, '/cache/fold-helper']);
187255
const mode = spawn.slice(3);
188256
helpers.push(mode);
189257
if (mode[0] === 'panels') {
190-
panelReads += 1;
191-
if (lagReadsLeft > 0) lagReadsLeft -= 1;
192-
else if (pendingPicker !== undefined) {
193-
pickerAngle = pendingPicker;
194-
pendingPicker = undefined;
195-
show(lit);
196-
}
197-
const bothLit =
198-
bothLitReads > 0 ||
199-
(options.bothLitFromRead !== undefined && panelReads >= options.bothLitFromRead);
200-
if (bothLitReads > 0) bothLitReads -= 1;
201-
const displays = [panel('outer', bothLit), panel('inner', bothLit)];
258+
const displays = readPanels();
202259
return { stdout: JSON.stringify({ version: 1, displays }), stderr: '', exitCode: 0 };
203260
}
204261
expect(mode[0]).toBe('rotate');
@@ -214,7 +271,8 @@ function fakeDuo(
214271
});
215272
return {
216273
helpers,
217-
devicectl,
274+
unexpectedTools,
275+
classificationReads,
218276
panelReads: () => panelReads,
219277
rotations: () => helpers.filter((mode) => mode[0] === 'rotate').map((mode) => mode[1]!),
220278
litScreen: () => `rot${shown[lit]}`,
@@ -238,16 +296,32 @@ async function rotateDuo(
238296
sim: ReturnType<typeof fakeDuo>,
239297
rotation: DeviceRotation,
240298
): Promise<unknown> {
241-
vi.useFakeTimers();
299+
const prepare = vi.mocked(ensureFoldHelperBinary).getMockImplementation()!;
300+
let clockStarted = false;
301+
let ready!: () => void;
302+
const helperStarted = new Promise<void>((resolve) => {
303+
ready = resolve;
304+
});
305+
vi.mocked(ensureFoldHelperBinary).mockImplementation(async (input) => {
306+
if (!clockStarted) {
307+
vi.useFakeTimers();
308+
clockStarted = true;
309+
ready();
310+
}
311+
return prepare(input);
312+
});
242313
try {
243314
const outcome = sim.rotate(rotation).then(
244315
(value) => value,
245316
(error: unknown) => error,
246317
);
247-
await vi.advanceTimersByTimeAsync(30_000);
318+
// CoreDevice classification performs file I/O before any settle timer exists.
319+
await Promise.race([helperStarted, outcome]);
320+
if (clockStarted) await vi.advanceTimersByTimeAsync(30_000);
248321
return await outcome;
249322
} finally {
250323
vi.useRealTimers();
324+
vi.mocked(ensureFoldHelperBinary).mockImplementation(prepare);
251325
}
252326
}
253327

@@ -266,7 +340,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
266340
await expect(rotateDuo(sim, target)).resolves.toEqual({ orientation: target });
267341
expect(sim.litScreen()).toBe(`rot${PANEL_ANGLE[target]}`);
268342
expect(sim.rotations()).toEqual(dispatched);
269-
expect(sim.devicectl).toEqual([]);
343+
expect(sim.unexpectedTools).toEqual([]);
270344
expect(mockRunner).not.toHaveBeenCalled();
271345
},
272346
);
@@ -280,7 +354,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
280354
expect(sim.litScreen()).toBe('rot0');
281355
// The measured inner offset makes the first dispatch exact: portrait - 90 is landscape-left.
282356
expect(sim.rotations()).toEqual(['landscape-left']);
283-
expect(sim.devicectl).toEqual([]);
357+
expect(sim.unexpectedTools).toEqual([]);
284358
expect(mockRunner).not.toHaveBeenCalled();
285359
});
286360

@@ -296,7 +370,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
296370
});
297371
expect(sim.litScreen()).toBe('rot270');
298372
expect(sim.rotations()).toEqual(['portrait-upside-down']);
299-
expect(sim.devicectl).toEqual([]);
373+
expect(sim.unexpectedTools).toEqual([]);
300374
});
301375

302376
test('the outer panel reads the picker value itself, so its first dispatch is the target', async () => {
@@ -308,7 +382,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
308382
await expect(rotateDuo(sim, rotation)).resolves.toEqual({ orientation: rotation });
309383
expect(sim.rotations()).toEqual([rotation]);
310384
}
311-
expect(sim.devicectl).toEqual([]);
385+
expect(sim.unexpectedTools).toEqual([]);
312386
});
313387

314388
test('an unexpected panel response cannot calibrate another picker value', async () => {
@@ -326,7 +400,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
326400
expect(sim.litScreen()).toBe('rot0');
327401
// An unexpected reading is not evidence that an unsupported picker value is safe to send.
328402
expect(sim.rotations()).toEqual(['landscape-right']);
329-
expect(sim.devicectl).toEqual([]);
403+
expect(sim.unexpectedTools).toEqual([]);
330404
});
331405

332406
test('the picker survives a fold in both directions and each panel adds its own offset', async () => {
@@ -463,7 +537,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
463537
});
464538
expect((outcome as AppError).details).not.toHaveProperty('observedOrientation');
465539
expect(sim.rotations()).toEqual([]);
466-
expect(sim.devicectl).toEqual([]);
540+
expect(sim.unexpectedTools).toEqual([]);
467541
},
468542
);
469543

@@ -489,7 +563,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
489563
});
490564
expect((outcome as AppError).details).not.toHaveProperty('observedOrientation');
491565
expect(sim.rotations()).toEqual([]);
492-
expect(sim.devicectl).toEqual([]);
566+
expect(sim.unexpectedTools).toEqual([]);
493567
},
494568
);
495569

@@ -526,7 +600,7 @@ describe.each([undefined, '/tmp/scoped-set'])('foldable rotation, set %s', (simu
526600
// The panel never moved after the one dispatch, so nothing more was
527601
// dispatched: the loop re-read until its budget ended, then refused.
528602
expect(sim.rotations()).toEqual(['landscape-left']);
529-
expect(sim.devicectl).toEqual([]);
603+
expect(sim.unexpectedTools).toEqual([]);
530604
});
531605

532606
test('a foldable still refuses portrait-upside-down as a target', async () => {

0 commit comments

Comments
 (0)