Skip to content

Commit 442a6ce

Browse files
committed
feat(limrun): replace iOS AX automation with the shared XCTest runner
1 parent 94a8d8c commit 442a6ce

121 files changed

Lines changed: 5430 additions & 5347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/adr/0004-ios-snapshot-backend-strategy.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ Thus, the pass returns that tree exactly as reported.
623623
This is a structural consequence of the same rule applied uniformly.
624624
It is not the per-tier omission that a hand-threaded space would have made it.
625625

626-
The provider producers (`appium-source`, `limrun-ios-tree`) never see the app's windows either.
626+
The provider producers (`appium-source`) never see the app's windows either.
627627
A capture from any of them publishes no keyboard fact.
628628
A consumer reads that silence as "this producer did not measure".
629629
Those paths publish what the platform reported.
@@ -728,7 +728,7 @@ Producers that report no UIKit class names or no dimming-view interaction state
728728
That is a fact about their output, not a backend exception in the fold:
729729

730730
- The runner already omits modal-contained content from its own queries.
731-
- `appium-source` and `limrun-ios-tree` report element types and no classes.
731+
- `appium-source` reports element types and no classes.
732732
- The macOS desktop surface arrives already presented.
733733

734734
A scope that names a modal-contained screen now publishes an empty projection.

‎packages/capture-kit/src/ios-ax-tree.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ function readIosNodeIdentifier(node: IosTreeNode): string | undefined {
119119
* The node's screen-space rect, or undefined when the provider omitted a complete one. A component that
120120
* is not a finite number (an AX sentinel such as an infinite origin) makes the frame unusable.
121121
*/
122-
export function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] {
122+
function readIosNodeRect(node: IosTreeNode): RawSnapshotNode['rect'] {
123123
const rect = node.rect ?? node.frame;
124124
if (
125125
!rect ||
@@ -137,7 +137,7 @@ function isFiniteNumber(value: unknown): value is number {
137137
return typeof value === 'number' && Number.isFinite(value);
138138
}
139139

140-
export function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] {
140+
function readIosNodeChildren(node: IosTreeNode): IosTreeNode[] {
141141
return node.children ?? node.nodes ?? node.elements ?? [];
142142
}
143143

‎packages/capture-kit/src/ios-snapshot-acquisition.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ const TREE_SOURCE_CAPABILITIES = Object.freeze({
4343
*/
4444
const IOS_PROVIDER_ACQUISITION_CAPABILITY_VALUES = {
4545
'appium-source': { producer: 'appium-source', ...TREE_SOURCE_CAPABILITIES },
46-
'limrun-ios-tree': { producer: 'limrun-ios-tree', ...TREE_SOURCE_CAPABILITIES },
4746
'revyl-ios-tree': { producer: 'revyl-ios-tree', ...TREE_SOURCE_CAPABILITIES },
4847
} as const satisfies Record<IosProviderAcquisitionProducer, IosProviderAcquisitionCapabilities>;
4948

@@ -63,7 +62,6 @@ const IOS_SNAPSHOT_TRUNCATION_EVIDENCE = {
6362
'apple-runner': 'available',
6463
'simulator-ax-bridge': 'available',
6564
'appium-source': 'unavailable',
66-
'limrun-ios-tree': 'unavailable',
6765
'revyl-ios-tree': 'unavailable',
6866
} as const satisfies Record<IosSnapshotProducer, IosSnapshotEvidenceAvailability>;
6967

‎packages/capture-kit/src/ios-snapshot-planning.test.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ test('normalization makes raw, scope, and absent values explicit', () => {
7373
});
7474

7575
test('acquisition derives unavailable provider facts from the registry', () => {
76-
for (const producer of ['appium-source', 'limrun-ios-tree'] as const) {
76+
for (const producer of ['appium-source', 'revyl-ios-tree'] as const) {
7777
assert.deepEqual(
7878
createIosSnapshotAcquisition({
7979
producer,
@@ -102,7 +102,6 @@ test('truncation evidence is declared for every iOS producer', () => {
102102
'apple-runner': 'available',
103103
'simulator-ax-bridge': 'available',
104104
'appium-source': 'unavailable',
105-
'limrun-ios-tree': 'unavailable',
106105
'revyl-ios-tree': 'unavailable',
107106
} as const satisfies Record<IosSnapshotProducer, IosSnapshotEvidenceAvailability>;
108107

@@ -114,7 +113,7 @@ test('truncation evidence is declared for every iOS producer', () => {
114113
test('comparison identity rejects every identity axis and residue mismatch', () => {
115114
const base = comparisonIdentity();
116115
const mismatches: IosSnapshotComparisonIdentity[] = [
117-
{ ...base, producer: 'limrun-ios-tree' },
116+
{ ...base, producer: 'revyl-ios-tree' },
118117
{ ...base, intent: 'surface-observation' },
119118
{ ...base, lineage: { targetId: 'simulator-1', generation: 'generation-2' } },
120119
{ ...base, presentationKey: { ...base.presentationKey, depth: 1 } },

‎packages/capture-kit/src/png-worker-client.test.ts‎

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
encodePngAsync,
1212
retainPngWorker,
1313
terminatePngWorker,
14-
transcodeScreenshotToPngAsync,
1514
} from './png-worker-client.ts';
1615
import { cropPngBytes } from './png-crop-bytes.ts';
1716
import { computeScreenshotDiffPixels } from './screenshot-diff-pixels.ts';
@@ -148,39 +147,6 @@ test('decodePngAsync rejects invalid PNG data with the canonical decode AppError
148147
);
149148
});
150149

151-
test('transcodeScreenshotToPngAsync matches the synchronous transcoder byte for byte', async () => {
152-
const { encode } = await import('jpeg-js');
153-
const { transcodeScreenshotToPng } = await import('./screenshot-image.ts');
154-
const rgba = Buffer.alloc(9 * 7 * 4, 0x66);
155-
const jpeg = encode({ width: 9, height: 7, data: rgba }, 90).data;
156-
157-
const fromWorker = await runOnWorkerThread('jpeg-to-png', () =>
158-
transcodeScreenshotToPngAsync(jpeg, 'fixture'),
159-
);
160-
161-
assert.deepEqual(fromWorker, transcodeScreenshotToPng(jpeg, 'fixture'));
162-
assert.equal(PNG.sync.read(fromWorker).width, 9);
163-
});
164-
165-
test('transcodeScreenshotToPngAsync returns a PNG untouched without a worker round trip', async () => {
166-
const png = PNG.sync.write(new PNG({ width: 2, height: 2 }));
167-
168-
assert.equal(await transcodeScreenshotToPngAsync(png, 'fixture'), png);
169-
});
170-
171-
test('transcodeScreenshotToPngAsync rejects a corrupt JPEG with the canonical decode AppError', async () => {
172-
const corrupt = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(16, 0)]);
173-
174-
await assert.rejects(transcodeScreenshotToPngAsync(corrupt, 'fixture'), (error) => {
175-
assert.equal(error instanceof AppError, true);
176-
assert.equal((error as AppError).code, 'COMMAND_FAILED');
177-
assert.match((error as AppError).message, /Failed to decode fixture as JPEG/);
178-
assert.equal((error as AppError).details?.label, 'fixture');
179-
assert.ok(String((error as AppError).details?.reason).length > 0);
180-
return true;
181-
});
182-
});
183-
184150
test('decodeScreenshotImageAsync answers the synchronous decoder for both containers', async () => {
185151
const { encode } = await import('jpeg-js');
186152
const { decodeScreenshotImage } = await import('./screenshot-image.ts');

‎packages/capture-kit/src/png-worker-client.ts‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Worker } from 'node:worker_threads';
22
import { AppError, toAppErrorCode } from '@agent-device/kernel/errors';
33
import type { Rect } from '@agent-device/kernel/snapshot';
44
import { resolveInternalEntryModulePath } from './internal-entry.ts';
5-
import { decodePng, hasPngSignature, PNG } from './png.ts';
5+
import { decodePng, PNG } from './png.ts';
66
import {
77
computeScreenshotDiffPixels,
88
type ScreenshotDiffPixelsJob,
@@ -315,19 +315,3 @@ export async function computeScreenshotDiffPixelsAsync(
315315
}));
316316
return { ...result, diffData: toBuffer(result.diffData) };
317317
}
318-
319-
/**
320-
* PNG bytes for a provider screenshot in whatever container it arrived in. A PNG returns as is
321-
* without a worker round trip; a JPEG is decoded and re-encoded on the worker so a full-resolution
322-
* capture never blocks the daemon event loop. Decode failures carry the canonical `AppError`.
323-
*/
324-
export async function transcodeScreenshotToPngAsync(bytes: Buffer, label: string): Promise<Buffer> {
325-
if (hasPngSignature(bytes)) return bytes;
326-
const result = await runPngJob({ kind: 'jpeg-to-png', image: bytes, label }, async () => {
327-
// Read on demand so the JPEG decoder stays out of the import closure of every entry that only
328-
// needs the worker's other jobs.
329-
const { transcodeScreenshotToPng } = await import('./screenshot-image.ts');
330-
return { kind: 'jpeg-to-png', png: transcodeScreenshotToPng(bytes, label) };
331-
});
332-
return toBuffer(result.png);
333-
}

‎packages/capture-kit/src/png-worker-contract.ts‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { PngRgbDifferenceResult } from './png-rgb-difference.ts';
99
/**
1010
* Message contract between the daemon-side PNG worker client
1111
* (`png-worker-client.ts`) and the worker thread entry (`png-worker.ts`).
12-
* One message = one decode, encode, crop, diff, or transcode job. Binary payloads cross the
12+
* One message = one decode, encode, crop, or diff job. Binary payloads cross the
1313
* thread boundary via structured clone (or transfer), so `Buffer` fields
1414
* arrive as plain `Uint8Array` views on the receiving side.
1515
*/
@@ -21,9 +21,7 @@ export type PngWorkerJob =
2121
| { kind: 'encode'; width: number; height: number; data: Uint8Array }
2222
| { kind: 'crop'; png: Uint8Array; label: string; box: Rect }
2323
| { kind: 'rgb-difference'; firstPng: Uint8Array; secondPng: Uint8Array; label: string }
24-
| ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsJob)
25-
// A provider screenshot in whatever container it arrived in; answers PNG bytes.
26-
| { kind: 'jpeg-to-png'; image: Uint8Array; label: string };
24+
| ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsJob);
2725

2826
export type PngWorkerJobResult =
2927
| { kind: 'decode'; width: number; height: number; data: Uint8Array }
@@ -32,8 +30,7 @@ export type PngWorkerJobResult =
3230
// A crop answers `null` when the box already covers the image, so the caller keeps the file.
3331
| { kind: 'crop'; png: Uint8Array | null }
3432
| ({ kind: 'rgb-difference' } & PngRgbDifferenceResult)
35-
| ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsResult)
36-
| { kind: 'jpeg-to-png'; png: Uint8Array };
33+
| ({ kind: 'diff-pixels' } & ScreenshotDiffPixelsResult);
3734

3835
export type PngWorkerJobKind = PngWorkerJob['kind'];
3936

‎packages/capture-kit/src/png-worker.ts‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ async function runJob(request: PngWorkerRequest): Promise<PngWorkerJobResult> {
5555
case 'diff-pixels': {
5656
return { kind: 'diff-pixels', ...computeScreenshotDiffPixels(request) };
5757
}
58-
case 'jpeg-to-png': {
59-
const { transcodeScreenshotToPng } = await import('./screenshot-image.ts');
60-
return {
61-
kind: 'jpeg-to-png',
62-
png: transcodeScreenshotToPng(toBuffer(request.image), request.label),
63-
};
64-
}
6558
}
6659
}
6760

@@ -102,8 +95,6 @@ function resultBufferViews(result: PngWorkerJobResult): Uint8Array[] {
10295
return [];
10396
case 'diff-pixels':
10497
return [result.diffData, result.diffMask];
105-
case 'jpeg-to-png':
106-
return [result.png];
10798
}
10899
}
109100

‎packages/capture-kit/src/screenshot-image.test.ts‎

Lines changed: 4 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { expect, test } from 'vitest';
22
import fc from 'fast-check';
33
import { encode as encodeJpeg } from 'jpeg-js';
44
import { PNG } from './png.ts';
5-
import {
6-
decodeScreenshotImage,
7-
detectScreenshotImageFormat,
8-
transcodeScreenshotToPng,
9-
} from './screenshot-image.ts';
5+
import { decodeScreenshotImage, detectScreenshotImageFormat } from './screenshot-image.ts';
106

117
function solidRgba(width: number, height: number, rgba: readonly [number, number, number, number]) {
128
const data = Buffer.alloc(width * height * 4);
@@ -24,32 +20,9 @@ function thrownBy(run: () => unknown): unknown {
2420
throw new Error('expected the call to throw');
2521
}
2622

27-
test('a JPEG screenshot becomes a PNG of the same size with the same picture', () => {
28-
const jpeg = encodeJpeg({ width: 6, height: 4, data: solidRgba(6, 4, [200, 30, 30, 255]) }, 100);
29-
expect(detectScreenshotImageFormat(jpeg.data)).toBe('jpeg');
30-
31-
const png = transcodeScreenshotToPng(jpeg.data, 'test screenshot');
32-
33-
expect(detectScreenshotImageFormat(png)).toBe('png');
34-
const decoded = PNG.sync.read(png);
35-
expect([decoded.width, decoded.height]).toEqual([6, 4]);
36-
const [r = -1, g = -1, b = -1, a = -1] = decoded.data.subarray(0, 4);
37-
// JPEG is lossy; a flat field survives within a few levels per channel.
38-
expect(Math.abs(r - 200)).toBeLessThanOrEqual(4);
39-
expect(Math.abs(g - 30)).toBeLessThanOrEqual(4);
40-
expect(Math.abs(b - 30)).toBeLessThanOrEqual(4);
41-
expect(a).toBe(255);
42-
});
43-
44-
test('a PNG screenshot passes through byte for byte', () => {
45-
const png = PNG.sync.write(new PNG({ width: 3, height: 2 }));
46-
47-
expect(transcodeScreenshotToPng(png, 'test screenshot')).toBe(png);
48-
});
49-
5023
test('bytes that are neither container are refused with a typed error carrying the label', () => {
5124
expect(
52-
thrownBy(() => transcodeScreenshotToPng(Buffer.from('GIF89a'), 'Limrun iOS screenshot')),
25+
thrownBy(() => decodeScreenshotImage(Buffer.from('GIF89a'), 'Limrun iOS screenshot')),
5326
).toMatchObject({
5427
code: 'COMMAND_FAILED',
5528
message: 'Limrun iOS screenshot is neither PNG nor JPEG',
@@ -61,7 +34,7 @@ test('a valid SOI marker followed by garbage is refused with the typed decode er
6134
const garbage = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.alloc(32, 0x41)]);
6235
expect(detectScreenshotImageFormat(garbage)).toBe('jpeg');
6336

64-
expect(thrownBy(() => transcodeScreenshotToPng(garbage, 'Limrun iOS screenshot'))).toMatchObject({
37+
expect(thrownBy(() => decodeScreenshotImage(garbage, 'Limrun iOS screenshot'))).toMatchObject({
6538
code: 'COMMAND_FAILED',
6639
message: 'Failed to decode Limrun iOS screenshot as JPEG',
6740
details: { label: 'Limrun iOS screenshot', reason: expect.any(String) },
@@ -72,9 +45,7 @@ test('a truncated JPEG body is refused with the same typed decode error', () =>
7245
const jpeg = encodeJpeg({ width: 6, height: 4, data: solidRgba(6, 4, [10, 200, 30, 255]) }, 100);
7346
const truncated = jpeg.data.subarray(0, 24);
7447

75-
expect(
76-
thrownBy(() => transcodeScreenshotToPng(truncated, 'Limrun iOS screenshot')),
77-
).toMatchObject({
48+
expect(thrownBy(() => decodeScreenshotImage(truncated, 'Limrun iOS screenshot'))).toMatchObject({
7849
code: 'COMMAND_FAILED',
7950
message: 'Failed to decode Limrun iOS screenshot as JPEG',
8051
});

‎packages/capture-kit/src/screenshot-image.ts‎

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,6 @@ export function decodeScreenshotImage(bytes: Buffer, label: string, maxPixels?:
3030
throw unsupportedContainer(label, bytes);
3131
}
3232

33-
/**
34-
* Returns PNG bytes for a screenshot a provider handed back in whatever container it prefers. PNG
35-
* passes through untouched; JPEG is decoded and re-encoded losslessly from the decoded pixels, so
36-
* every PNG-only reader downstream (size, crop, overlay) sees the format the path promises. Those
37-
* readers rewrite the file in place, which is why a JPEG cannot be handed to them undecoded.
38-
*
39-
* Synchronous and CPU-bound: daemon request paths reach it through the PNG worker
40-
* (`transcodeScreenshotToPngAsync` in `png-worker-client.ts`), like every other codec job.
41-
*/
42-
export function transcodeScreenshotToPng(bytes: Buffer, label: string): Buffer {
43-
const format = detectScreenshotImageFormat(bytes);
44-
if (format === 'png') return bytes;
45-
if (format === 'jpeg') return PNG.sync.write(decodeJpegImage(bytes, label));
46-
throw unsupportedContainer(label, bytes);
47-
}
48-
4933
/** A JPEG signature does not prove a decodable body; a failure keeps the label and the decoder's reason. */
5034
function decodeJpegImage(bytes: Buffer, label: string, maxPixels?: number): PNG {
5135
try {

0 commit comments

Comments
 (0)