Skip to content

Commit 1987198

Browse files
authored
refactor: split session recording modules (#528)
1 parent e4e05ec commit 1987198

8 files changed

Lines changed: 619 additions & 555 deletions

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

Lines changed: 164 additions & 175 deletions
Large diffs are not rendered by default.

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

Lines changed: 50 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
11
import fs from 'node:fs';
22
import { AppError } from '../../utils/errors.ts';
33
import type { PlatformSelector } from '../../utils/device.ts';
4-
import { appendOpenActionScriptArgs, parseReplayOpenFlags } from '../session-open-script.ts';
4+
import { parseReplayOpenFlags } from '../session-open-script.ts';
5+
import { formatPortableActionLine } from '../session-script-formatting.ts';
56
import type { SessionAction, SessionState } from '../types.ts';
67
import {
7-
appendRecordActionScriptArgs,
8-
appendRuntimeHintFlags,
9-
appendScriptSeriesFlags,
10-
formatScriptArgQuoteIfNeeded,
11-
formatScriptArg,
128
formatScriptStringLiteral,
139
isClickLikeCommand,
1410
parseReplaySeriesFlags,
@@ -381,36 +377,57 @@ function tokenizeReplayLine(line: string): string[] {
381377
const tokens: string[] = [];
382378
let cursor = 0;
383379
while (cursor < line.length) {
384-
while (cursor < line.length && /\s/.test(line[cursor])) {
385-
cursor += 1;
386-
}
380+
cursor = skipReplayWhitespace(line, cursor);
387381
if (cursor >= line.length) break;
388-
if (line[cursor] === '"') {
389-
let end = cursor + 1;
390-
let escaped = false;
391-
while (end < line.length) {
392-
const char = line[end];
393-
if (char === '"' && !escaped) break;
394-
escaped = char === '\\' && !escaped;
395-
if (char !== '\\') escaped = false;
396-
end += 1;
397-
}
398-
if (end >= line.length) {
399-
throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`);
400-
}
401-
const literal = line.slice(cursor, end + 1);
402-
tokens.push(JSON.parse(literal) as string);
403-
cursor = end + 1;
382+
const parsed =
383+
line[cursor] === '"'
384+
? readQuotedReplayToken(line, cursor)
385+
: readBareReplayToken(line, cursor);
386+
tokens.push(parsed.value);
387+
cursor = parsed.nextCursor;
388+
}
389+
return tokens;
390+
}
391+
392+
function skipReplayWhitespace(line: string, cursor: number): number {
393+
let nextCursor = cursor;
394+
while (nextCursor < line.length && /\s/.test(line[nextCursor])) {
395+
nextCursor += 1;
396+
}
397+
return nextCursor;
398+
}
399+
400+
function readQuotedReplayToken(
401+
line: string,
402+
cursor: number,
403+
): { value: string; nextCursor: number } {
404+
const tokenStart = cursor + 1;
405+
let escaped = false;
406+
let end = tokenStart;
407+
for (; end < line.length; end += 1) {
408+
const char = line[end];
409+
if (char === '"' && !escaped) break;
410+
if (escaped) {
411+
escaped = false;
404412
continue;
405413
}
406-
let end = cursor;
407-
while (end < line.length && !/\s/.test(line[end])) {
408-
end += 1;
409-
}
410-
tokens.push(line.slice(cursor, end));
411-
cursor = end;
414+
escaped = char === '\\';
412415
}
413-
return tokens;
416+
if (end >= line.length) {
417+
throw new AppError('INVALID_ARGS', `Invalid replay script line: ${line}`);
418+
}
419+
return {
420+
value: JSON.parse(line.slice(cursor, end + 1)) as string,
421+
nextCursor: end + 1,
422+
};
423+
}
424+
425+
function readBareReplayToken(line: string, cursor: number): { value: string; nextCursor: number } {
426+
let end = cursor;
427+
while (end < line.length && !/\s/.test(line[end])) {
428+
end += 1;
429+
}
430+
return { value: line.slice(cursor, end), nextCursor: end };
414431
}
415432

416433
export function writeReplayScript(
@@ -438,47 +455,5 @@ export function writeReplayScript(
438455
}
439456

440457
function formatReplayActionLine(action: SessionAction): string {
441-
const parts: string[] = [action.command];
442-
if (action.command === 'snapshot') {
443-
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
444-
if (action.flags?.snapshotCompact) parts.push('-c');
445-
if (typeof action.flags?.snapshotDepth === 'number') {
446-
parts.push('-d', String(action.flags.snapshotDepth));
447-
}
448-
if (action.flags?.snapshotScope) {
449-
parts.push('-s', formatScriptArg(action.flags.snapshotScope));
450-
}
451-
if (action.flags?.snapshotRaw) parts.push('--raw');
452-
return parts.join(' ');
453-
}
454-
if (action.command === 'open') {
455-
appendOpenActionScriptArgs(parts, action);
456-
return parts.join(' ');
457-
}
458-
if (action.command === 'runtime') {
459-
for (const positional of action.positionals ?? []) {
460-
parts.push(formatScriptArgQuoteIfNeeded(positional));
461-
}
462-
appendRuntimeHintFlags(parts, action.flags);
463-
return parts.join(' ');
464-
}
465-
if (action.command === 'record') {
466-
appendRecordActionScriptArgs(parts, action);
467-
return parts.join(' ');
468-
}
469-
if (action.command === 'screenshot') {
470-
for (const positional of action.positionals ?? []) {
471-
parts.push(formatScriptArg(positional));
472-
}
473-
if (action.flags?.screenshotFullscreen) parts.push('--fullscreen');
474-
if (typeof action.flags?.screenshotMaxSize === 'number') {
475-
parts.push('--max-size', String(action.flags.screenshotMaxSize));
476-
}
477-
return parts.join(' ');
478-
}
479-
for (const positional of action.positionals ?? []) {
480-
parts.push(formatScriptArg(positional));
481-
}
482-
appendScriptSeriesFlags(parts, action);
483-
return parts.join(' ');
458+
return formatPortableActionLine(action, { runtimeIncludeAllPositionals: true });
484459
}

src/daemon/script-utils.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function formatScriptStringLiteral(value: string): string {
3737
}
3838

3939
// Preserve readable CLI-ish script output for ordinary tokens while still quoting whitespace.
40-
export function formatScriptArgQuoteIfNeeded(value: string): string {
40+
function formatScriptArgQuoteIfNeeded(value: string): string {
4141
return formatScriptToken(value, isBareScriptToken);
4242
}
4343

@@ -138,6 +138,48 @@ export function appendRecordActionScriptArgs(parts: string[], action: SessionAct
138138
}
139139
}
140140

141+
export function appendSnapshotActionScriptArgs(parts: string[], action: SessionAction): void {
142+
if (action.flags?.snapshotInteractiveOnly) parts.push('-i');
143+
if (action.flags?.snapshotCompact) parts.push('-c');
144+
if (typeof action.flags?.snapshotDepth === 'number') {
145+
parts.push('-d', String(action.flags.snapshotDepth));
146+
}
147+
if (action.flags?.snapshotScope) {
148+
parts.push('-s', formatScriptArg(action.flags.snapshotScope));
149+
}
150+
if (action.flags?.snapshotRaw) parts.push('--raw');
151+
}
152+
153+
export function appendScreenshotActionScriptArgs(parts: string[], action: SessionAction): void {
154+
for (const positional of action.positionals ?? []) {
155+
parts.push(formatScriptArg(positional));
156+
}
157+
if (action.flags?.screenshotFullscreen) parts.push('--fullscreen');
158+
if (typeof action.flags?.screenshotMaxSize === 'number') {
159+
parts.push('--max-size', String(action.flags.screenshotMaxSize));
160+
}
161+
}
162+
163+
export function appendRuntimeActionScriptArgs(
164+
parts: string[],
165+
action: SessionAction,
166+
options: { includeAllPositionals?: boolean } = {},
167+
): void {
168+
const positionals = action.positionals ?? [];
169+
const selectedPositionals = options.includeAllPositionals ? positionals : positionals.slice(0, 1);
170+
for (const positional of selectedPositionals) {
171+
parts.push(formatScriptArgQuoteIfNeeded(positional));
172+
}
173+
appendRuntimeHintFlags(parts, action.flags);
174+
}
175+
176+
export function appendGenericActionScriptArgs(parts: string[], action: SessionAction): void {
177+
for (const positional of action.positionals ?? []) {
178+
parts.push(formatScriptArg(positional));
179+
}
180+
appendScriptSeriesFlags(parts, action);
181+
}
182+
141183
export function parseReplaySeriesFlags(
142184
command: string,
143185
args: string[],
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { CommandFlags } from '../core/dispatch.ts';
2+
import { emitDiagnostic } from '../utils/diagnostics.ts';
3+
import type { SessionAction, SessionRuntimeHints, SessionState } from './types.ts';
4+
import { expandSessionPath } from './session-paths.ts';
5+
6+
export type RecordActionEntry = {
7+
command: string;
8+
positionals: string[];
9+
flags: CommandFlags;
10+
runtime?: SessionRuntimeHints;
11+
result?: Record<string, unknown>;
12+
};
13+
14+
export function recordActionEntry(session: SessionState, entry: RecordActionEntry): void {
15+
if (entry.flags?.noRecord) return;
16+
if (entry.flags?.saveScript) {
17+
session.recordSession = true;
18+
if (typeof entry.flags.saveScript === 'string') {
19+
session.saveScriptPath = expandSessionPath(entry.flags.saveScript);
20+
}
21+
}
22+
session.actions.push({
23+
ts: Date.now(),
24+
command: entry.command,
25+
positionals: entry.positionals,
26+
runtime: entry.runtime,
27+
flags: sanitizeFlags(entry.flags),
28+
result: entry.result,
29+
});
30+
emitDiagnostic({
31+
level: 'debug',
32+
phase: 'record_action',
33+
data: {
34+
command: entry.command,
35+
session: session.name,
36+
},
37+
});
38+
}
39+
40+
const SANITIZED_FLAG_KEYS = [
41+
'platform',
42+
'device',
43+
'udid',
44+
'serial',
45+
'out',
46+
'verbose',
47+
'metroHost',
48+
'metroPort',
49+
'bundleUrl',
50+
'launchUrl',
51+
'snapshotInteractiveOnly',
52+
'snapshotCompact',
53+
'snapshotDepth',
54+
'snapshotScope',
55+
'snapshotRaw',
56+
'screenshotFullscreen',
57+
'screenshotMaxSize',
58+
'relaunch',
59+
'saveScript',
60+
'noRecord',
61+
'fps',
62+
'quality',
63+
'hideTouches',
64+
'count',
65+
'intervalMs',
66+
'delayMs',
67+
'holdMs',
68+
'jitterPx',
69+
'doubleTap',
70+
'clickButton',
71+
'pauseMs',
72+
'pattern',
73+
] as const satisfies readonly (keyof CommandFlags)[];
74+
75+
function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags'] {
76+
if (!flags) return {};
77+
const result: Record<string, unknown> = {};
78+
for (const key of SANITIZED_FLAG_KEYS) {
79+
if (flags[key] !== undefined) {
80+
result[key] = flags[key];
81+
}
82+
}
83+
return result as SessionAction['flags'];
84+
}

src/daemon/session-paths.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { resolveUserPath } from '../utils/path-resolution.ts';
2+
3+
export function safeSessionName(name: string): string {
4+
return name.replace(/[^a-zA-Z0-9._-]/g, '_');
5+
}
6+
7+
export function expandSessionPath(filePath: string, cwd?: string): string {
8+
return resolveUserPath(filePath, { cwd });
9+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { appendOpenActionScriptArgs } from './session-open-script.ts';
2+
import {
3+
appendGenericActionScriptArgs,
4+
appendRecordActionScriptArgs,
5+
appendRuntimeActionScriptArgs,
6+
appendScreenshotActionScriptArgs,
7+
appendSnapshotActionScriptArgs,
8+
} from './script-utils.ts';
9+
import type { SessionAction } from './types.ts';
10+
11+
export function formatPortableActionLine(
12+
action: SessionAction,
13+
options: { runtimeIncludeAllPositionals?: boolean } = {},
14+
): string {
15+
const parts: string[] = [action.command];
16+
if (action.command === 'snapshot') {
17+
appendSnapshotActionScriptArgs(parts, action);
18+
} else if (action.command === 'open') {
19+
appendOpenActionScriptArgs(parts, action);
20+
} else if (action.command === 'runtime') {
21+
appendRuntimeActionScriptArgs(parts, action, {
22+
includeAllPositionals: options.runtimeIncludeAllPositionals,
23+
});
24+
} else if (action.command === 'record') {
25+
appendRecordActionScriptArgs(parts, action);
26+
} else if (action.command === 'screenshot') {
27+
appendScreenshotActionScriptArgs(parts, action);
28+
} else {
29+
appendGenericActionScriptArgs(parts, action);
30+
}
31+
return parts.join(' ');
32+
}

0 commit comments

Comments
 (0)