Skip to content

Commit 84098be

Browse files
authored
fix: preserve script argument whitespace (#397)
1 parent 6cf63c2 commit 84098be

4 files changed

Lines changed: 122 additions & 7 deletions

File tree

src/daemon/__tests__/session-store.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,3 +367,47 @@ test('writeSessionLog escapes device labels with quotes and backslashes', () =>
367367
/context platform=ios device="QA \\"Lab\\" \\\\ Shelf" kind=simulator theme=unknown/,
368368
);
369369
});
370+
371+
test('writeSessionLog preserves significant whitespace and empty string arguments', () => {
372+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-whitespace-'));
373+
const store = new SessionStore(root);
374+
const session = makeSession('default');
375+
store.recordAction(session, {
376+
command: 'open',
377+
positionals: ['Settings'],
378+
flags: { platform: 'ios', saveScript: true },
379+
runtime: {
380+
platform: 'ios',
381+
metroHost: ' host\t',
382+
launchUrl: 'myapp://dev ',
383+
},
384+
result: {},
385+
});
386+
store.recordAction(session, {
387+
command: 'type',
388+
positionals: [' leading\ttrailing '],
389+
flags: { platform: 'ios' },
390+
result: {},
391+
});
392+
store.recordAction(session, {
393+
command: 'fill',
394+
positionals: ['@e5', ''],
395+
flags: { platform: 'ios' },
396+
result: { refLabel: 'Search field' },
397+
});
398+
store.recordAction(session, {
399+
command: 'screenshot',
400+
positionals: [' ./screens/final.png '],
401+
flags: { platform: 'ios' },
402+
result: {},
403+
});
404+
405+
store.writeSessionLog(session);
406+
const scriptFile = fs.readdirSync(root).find((file) => file.endsWith('.ad'));
407+
assert.ok(scriptFile);
408+
const script = fs.readFileSync(path.join(root, scriptFile!), 'utf8');
409+
assert.match(script, /type " leading\\ttrailing "/);
410+
assert.match(script, /fill @e5 "Search field" ""/);
411+
assert.match(script, /screenshot " \.\/screens\/final\.png "/);
412+
assert.match(script, /--metro-host " host\\t" --launch-url "myapp:\/\/dev "/);
413+
});

src/daemon/handlers/__tests__/session-replay-script.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,67 @@ test('writeReplayScript escapes device labels with quotes and backslashes', () =
145145
);
146146
});
147147

148+
test('writeReplayScript preserves significant whitespace and empty string arguments', () => {
149+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-script-whitespace-'));
150+
const replayPath = path.join(root, 'flow.ad');
151+
const actions: SessionAction[] = [
152+
{
153+
ts: Date.now(),
154+
command: 'type',
155+
positionals: [' leading\ttrailing '],
156+
flags: {},
157+
},
158+
{
159+
ts: Date.now(),
160+
command: 'fill',
161+
positionals: ['@e2', ''],
162+
flags: {},
163+
},
164+
{
165+
ts: Date.now(),
166+
command: 'screenshot',
167+
positionals: [' ./screens/final.png '],
168+
flags: {},
169+
},
170+
{
171+
ts: Date.now(),
172+
command: 'screenshot',
173+
positionals: ['foo\\nbar.png'],
174+
flags: {},
175+
},
176+
{
177+
ts: Date.now(),
178+
command: 'open',
179+
positionals: ['Demo'],
180+
runtime: {
181+
platform: 'android',
182+
metroHost: ' host\t',
183+
launchUrl: 'myapp://dev ',
184+
},
185+
flags: {},
186+
},
187+
];
188+
189+
writeReplayScript(replayPath, actions, makeSession());
190+
const script = fs.readFileSync(replayPath, 'utf8');
191+
192+
assert.match(script, /type " leading\\ttrailing "/);
193+
assert.match(script, /fill @e2 ""/);
194+
assert.match(script, /screenshot " \.\/screens\/final\.png "/);
195+
assert.match(script, /screenshot "foo\\\\nbar\.png"/);
196+
assert.match(script, /--metro-host " host\\t" --launch-url "myapp:\/\/dev "/);
197+
const parsed = parseReplayScript(script);
198+
assert.deepEqual(parsed[0]?.positionals, [' leading\ttrailing ']);
199+
assert.deepEqual(parsed[1]?.positionals, ['@e2', '']);
200+
assert.deepEqual(parsed[2]?.positionals, [' ./screens/final.png ']);
201+
assert.deepEqual(parsed[3]?.positionals, ['foo\\nbar.png']);
202+
assert.deepEqual(parsed[4]?.runtime, {
203+
platform: 'android',
204+
metroHost: ' host\t',
205+
launchUrl: 'myapp://dev ',
206+
});
207+
});
208+
148209
test('readReplayScriptMetadata extracts platform from context header', () => {
149210
const metadata = readReplayScriptMetadata(
150211
'# comment\n\ncontext platform=android device="Pixel 9 Pro"\nopen "Demo"\n',

src/daemon/script-utils.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SessionAction } from './types.ts';
22

33
const NUMERIC_ARG_RE = /^-?\d+(\.\d+)?$/;
4+
const BARE_SCRIPT_TOKEN_RE = /^[^\s"\\]+$/;
45

56
const CLICK_LIKE_NUMERIC_FLAG_MAP = new Map<string, 'count' | 'intervalMs' | 'holdMs' | 'jitterPx'>(
67
[
@@ -27,10 +28,7 @@ function isTypingCommand(command: string): command is 'type' | 'fill' {
2728
}
2829

2930
export function formatScriptArg(value: string): string {
30-
const trimmed = value.trim();
31-
if (trimmed.startsWith('@')) return trimmed;
32-
if (NUMERIC_ARG_RE.test(trimmed)) return trimmed;
33-
return JSON.stringify(trimmed);
31+
return formatScriptToken(value, isStructuralScriptToken);
3432
}
3533

3634
// Use for literal values such as device labels where leading/trailing whitespace must survive round-trips.
@@ -40,8 +38,19 @@ export function formatScriptStringLiteral(value: string): string {
4038

4139
// Preserve readable CLI-ish script output for ordinary tokens while still quoting whitespace.
4240
export function formatScriptArgQuoteIfNeeded(value: string): string {
43-
const trimmed = value.trim();
44-
return /\s/.test(trimmed) ? JSON.stringify(trimmed) : trimmed;
41+
return formatScriptToken(value, isBareScriptToken);
42+
}
43+
44+
function formatScriptToken(value: string, canStayBare: (value: string) => boolean): string {
45+
return canStayBare(value) ? value : formatScriptStringLiteral(value);
46+
}
47+
48+
function isStructuralScriptToken(value: string): boolean {
49+
return (isBareScriptToken(value) && value.startsWith('@')) || NUMERIC_ARG_RE.test(value);
50+
}
51+
52+
function isBareScriptToken(value: string): boolean {
53+
return BARE_SCRIPT_TOKEN_RE.test(value);
4554
}
4655

4756
export function formatScriptActionSummary(action: SessionAction): string {

src/daemon/session-store.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,8 @@ function formatActionLine(action: SessionAction): string {
304304
if (typeof refLabel === 'string' && refLabel.trim().length > 0) {
305305
parts.push(formatScriptArg(refLabel));
306306
}
307-
if (text) {
307+
// Preserve explicit empty-string fill arguments.
308+
if (action.positionals.length > 1) {
308309
parts.push(formatScriptArg(text));
309310
}
310311
appendScriptSeriesFlags(parts, action);

0 commit comments

Comments
 (0)