mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
e7c6f3324c
## Problem
`describe` returned an empty tree after ~15 s whenever the screen had an
accessibility label containing U+2028 (LINE SEPARATOR) — e.g. any
Element/Matrix reply preview, or rich text pasted from the web. Nothing
was wrong on the device: ax-service answered in ~170 ms with a full,
valid tree.
The tool-server framed the ax-service socket with
`readline.createInterface`, which splits on the four ECMAScript line
terminators (`\n`, `\r`, **U+2028, U+2029**). JSON permits U+2028
unescaped inside a string and `NSJSONSerialization` emits it raw, so one
valid frame became three fragments, none parsed, each was dropped by
`catch { return }`, and the pending RPC waited out its 10 s timeout —
then the native-devtools fallback waited out its 5 s. On top of that,
the timeout was reported as *"this simulator was not booted through
argent … call boot-device with force=true"*, sending the agent to reboot
a healthy simulator.
Minimal repro (no device): `JSON.stringify({label:"a
b"})+"\n"` through
`readline` → 2 lines, 0 parsed.
## Fix
- **`utils/ndjson-socket.ts`** (new): `attachNdjsonReader` frames on the
`\n` byte only (`0x0A` can never occur inside a multi-byte UTF-8
sequence, so this is exact) and reports every unparseable frame to
stderr instead of swallowing it.
- Wired into the three device-facing JSON sockets: `ax-service.ts`,
`native-devtools.ts`, `android-devtools-client.ts` (the Android helper's
`org.json` and the iOS dylib's `NSJSONSerialization` both emit U+2028
raw, so all three were exposed). The two remaining `readline` uses read
process **stdout** for readiness markers — no JSON, no user text — and
are left alone.
- **`describe/platforms/ios/index.ts`**: a throw from `axApi.describe()`
(timeout, RPC error) now carries the error message in the hint and uses
the resolved api's real `degraded` flag, instead of being reported as
the boot caveat. Separate commit so it can be reverted independently.
## Verification
- 6 new unit tests for the reader (U+2028/U+2029 in a string, chunk
split mid-multibyte char, several frames per chunk, CRLF/empty lines,
dropped-frame reporting, trailing frame on `end`) and 3 for the hint
(`describe-tool.test.ts`). Full tool-server suite: 4816 passed.
- Live A/B on iPhone 16 / iOS 18.5, Safari page with `<meta
charset=utf-8>` and a U+2028 inside a `<p>`:
- argent 0.22.1 (`main`): `describe` **12.6 s**, `source:
native-devtools`, web text missing, false "not booted through argent"
hint.
- This branch (`dist` against installed binaries): **50 ms**, `source:
ax-service`, label `"In reply to Dave
Loving it so far"` present, no
hint, 0 dropped frames logged.
## Docs
No docs update needed — no tool, CLI, config or flow surface changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01VwqqHRhfuHXbKr6LYskxhJ
77 lines
2.9 KiB
TypeScript
77 lines
2.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { PassThrough } from "node:stream";
|
|
import type * as net from "node:net";
|
|
import { attachNdjsonReader } from "../src/utils/ndjson-socket";
|
|
|
|
// A PassThrough stands in for the net.Socket: the reader only needs
|
|
// setEncoding / on("data") / on("end").
|
|
function harness() {
|
|
const stream = new PassThrough();
|
|
const messages: unknown[] = [];
|
|
const dropped: { bytes: number; preview: string }[] = [];
|
|
attachNdjsonReader(stream as unknown as net.Socket, {
|
|
onMessage: (m) => messages.push(m),
|
|
onDropped: (d) => dropped.push(d),
|
|
});
|
|
return { stream, messages, dropped };
|
|
}
|
|
|
|
const settle = () => new Promise((r) => setImmediate(r));
|
|
|
|
describe("attachNdjsonReader", () => {
|
|
it("keeps U+2028 / U+2029 inside a string in one frame (the Element stall)", async () => {
|
|
const { stream, messages, dropped } = harness();
|
|
const label = "In reply to Dave\u2028Loving it so far\u2029next";
|
|
stream.write(JSON.stringify({ id: 1, result: { label } }) + "\n");
|
|
await settle();
|
|
expect(messages).toEqual([{ id: 1, result: { label } }]);
|
|
expect(dropped).toEqual([]);
|
|
});
|
|
|
|
it("reassembles a frame split across chunks, including mid multi-byte character", async () => {
|
|
const { stream, messages } = harness();
|
|
const bytes = Buffer.from(JSON.stringify({ id: 2, label: "café\u2028ünïcode" }) + "\n", "utf8");
|
|
// Cut inside the 3-byte U+2028 sequence.
|
|
const cut = bytes.indexOf(Buffer.from("\u2028", "utf8")) + 1;
|
|
stream.write(bytes.subarray(0, cut));
|
|
await settle();
|
|
expect(messages).toEqual([]);
|
|
stream.write(bytes.subarray(cut));
|
|
await settle();
|
|
expect(messages).toEqual([{ id: 2, label: "café\u2028ünïcode" }]);
|
|
});
|
|
|
|
it("delivers several frames arriving in one chunk, in order", async () => {
|
|
const { stream, messages } = harness();
|
|
stream.write('{"id":1}\n{"id":2}\n{"id":3}\n');
|
|
await settle();
|
|
expect(messages.map((m) => (m as { id: number }).id)).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it("skips empty lines and tolerates CRLF", async () => {
|
|
const { stream, messages, dropped } = harness();
|
|
stream.write('\n{"id":1}\r\n\r\n{"id":2}\n');
|
|
await settle();
|
|
expect(messages).toEqual([{ id: 1 }, { id: 2 }]);
|
|
expect(dropped).toEqual([]);
|
|
});
|
|
|
|
it("reports an unparseable frame with byte length and a sanitised preview, then continues", async () => {
|
|
const { stream, messages, dropped } = harness();
|
|
stream.write("garbage\there\n" + '{"id":9}\n');
|
|
await settle();
|
|
expect(messages).toEqual([{ id: 9 }]);
|
|
expect(dropped).toHaveLength(1);
|
|
expect(dropped[0]!.bytes).toBe(Buffer.byteLength("garbage\there"));
|
|
expect(dropped[0]!.preview).toBe("garbage·here");
|
|
});
|
|
|
|
it("delivers a trailing frame without newline when the stream ends", async () => {
|
|
const { stream, messages } = harness();
|
|
stream.write('{"id":7}');
|
|
stream.end();
|
|
await settle();
|
|
expect(messages).toEqual([{ id: 7 }]);
|
|
});
|
|
});
|