speqs 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/simulation.js +66 -1
- package/dist/commands/study.js +7 -7
- package/dist/lib/api-client.d.ts +30 -0
- package/dist/lib/api-client.js +16 -0
- package/dist/lib/local-sim/actions.d.ts +22 -0
- package/dist/lib/local-sim/actions.js +379 -0
- package/dist/lib/local-sim/browser.d.ts +63 -0
- package/dist/lib/local-sim/browser.js +332 -0
- package/dist/lib/local-sim/debug-report.d.ts +21 -0
- package/dist/lib/local-sim/debug-report.js +186 -0
- package/dist/lib/local-sim/debug.d.ts +44 -0
- package/dist/lib/local-sim/debug.js +103 -0
- package/dist/lib/local-sim/install.d.ts +25 -0
- package/dist/lib/local-sim/install.js +72 -0
- package/dist/lib/local-sim/loop.d.ts +60 -0
- package/dist/lib/local-sim/loop.js +526 -0
- package/dist/lib/local-sim/types.d.ts +232 -0
- package/dist/lib/local-sim/types.js +8 -0
- package/dist/lib/local-sim/upload.d.ts +6 -0
- package/dist/lib/local-sim/upload.js +24 -0
- package/dist/lib/types.js +5 -5
- package/package.json +4 -3
|
@@ -16,8 +16,16 @@ function parseMaxInteractions(value) {
|
|
|
16
16
|
throw new Error(`Invalid --max-interactions value: ${value}`);
|
|
17
17
|
return n;
|
|
18
18
|
}
|
|
19
|
+
function parseSlowMo(value) {
|
|
20
|
+
const n = parseInt(value, 10);
|
|
21
|
+
if (isNaN(n) || n < 0)
|
|
22
|
+
throw new Error(`Invalid --slow-mo value: ${value}`);
|
|
23
|
+
return n;
|
|
24
|
+
}
|
|
19
25
|
import { MEDIA_MODALITIES } from "../lib/types.js";
|
|
20
26
|
import { resolveContentUrl, resolveContentUrls, resolveTextContent } from "../lib/upload.js";
|
|
27
|
+
import { runLocalSimulations } from "../lib/local-sim/loop.js";
|
|
28
|
+
import { ensureBrowser } from "../lib/local-sim/install.js";
|
|
21
29
|
function isMediaModality(modality) {
|
|
22
30
|
return !!modality && MEDIA_MODALITIES.includes(modality);
|
|
23
31
|
}
|
|
@@ -164,6 +172,13 @@ export function registerSimulationCommands(program) {
|
|
|
164
172
|
.option("--language <lang>", "Language code (e.g. en, sv)")
|
|
165
173
|
.option("--locale <locale>", "Locale code (e.g. en-US)")
|
|
166
174
|
.option("-y, --yes", "Skip confirmation prompt")
|
|
175
|
+
// Local simulation options
|
|
176
|
+
.option("--local", "Run simulation with local browser (Playwright) instead of remote")
|
|
177
|
+
.option("--headed", "Show browser window (local mode only)")
|
|
178
|
+
.option("--slow-mo <ms>", "Slow down actions by ms (local mode only)")
|
|
179
|
+
.option("--devtools", "Open Chrome DevTools (local mode only)")
|
|
180
|
+
.option("--debug", "Enable detailed debug logging to stderr and ~/.speqs/local-sim.log")
|
|
181
|
+
.option("--parallel <n>", "Run N testers in parallel (local mode only, default: all)")
|
|
167
182
|
.addHelpText("after", `
|
|
168
183
|
Note: --workspace and --study are optional if you have set active context
|
|
169
184
|
via \`speqs workspace use <alias>\` and \`speqs study use <alias>\`.
|
|
@@ -196,7 +211,11 @@ Examples:
|
|
|
196
211
|
$ speqs sim run --image-urls ./post.png --copy-text @./caption.txt --social-platform instagram --config c-c3c
|
|
197
212
|
|
|
198
213
|
# Re-run existing iteration:
|
|
199
|
-
$ speqs sim run --iteration i-d4e
|
|
214
|
+
$ speqs sim run --iteration i-d4e
|
|
215
|
+
|
|
216
|
+
# Local browser simulation (no remote Browserbase):
|
|
217
|
+
$ speqs sim run --local --url http://localhost:3000
|
|
218
|
+
$ speqs sim run --local --url http://localhost:3000 --headed --slow-mo 500`)
|
|
200
219
|
.action(async (opts, cmd) => {
|
|
201
220
|
await withClient(cmd, async (client, globals) => {
|
|
202
221
|
const log = (msg) => { if (!globals.quiet)
|
|
@@ -373,6 +392,10 @@ Examples:
|
|
|
373
392
|
}
|
|
374
393
|
log("");
|
|
375
394
|
}
|
|
395
|
+
// Ensure browser is ready before creating server-side state
|
|
396
|
+
if (opts.local) {
|
|
397
|
+
await ensureBrowser({ quiet: globals.quiet, skipPrompt: globals.json });
|
|
398
|
+
}
|
|
376
399
|
// Step 1: Create or use existing iteration
|
|
377
400
|
if (!iterationId) {
|
|
378
401
|
const iterName = resolvedOpts.iterationName || `CLI ${new Date().toISOString().slice(0, 16)}`;
|
|
@@ -385,6 +408,11 @@ Examples:
|
|
|
385
408
|
iterationId = iter.id;
|
|
386
409
|
log(`Created iteration "${iterName}"`);
|
|
387
410
|
}
|
|
411
|
+
else if (!opts.iteration) {
|
|
412
|
+
// Auto-reused iteration — update its details to reflect current run
|
|
413
|
+
const newDetails = buildIterationDetails(modality, resolvedOpts);
|
|
414
|
+
await client.put(`/iterations/${iterationId}`, { details: newDetails });
|
|
415
|
+
}
|
|
388
416
|
// Step 2: Create testers from profiles (or reuse from explicit iteration)
|
|
389
417
|
let createdTesters;
|
|
390
418
|
if (opts.iteration && !opts.profiles) {
|
|
@@ -412,6 +440,43 @@ Examples:
|
|
|
412
440
|
log(`Created ${createdTesters.length} tester${createdTesters.length > 1 ? "s" : ""}`);
|
|
413
441
|
}
|
|
414
442
|
// Step 3: Start simulations
|
|
443
|
+
// Local mode: run simulations with local browser
|
|
444
|
+
if (opts.local) {
|
|
445
|
+
if (isMedia) {
|
|
446
|
+
throw new Error("Local mode is only supported for interactive simulations.");
|
|
447
|
+
}
|
|
448
|
+
const testerNameMap = new Map();
|
|
449
|
+
for (const t of createdTesters) {
|
|
450
|
+
testerNameMap.set(t.id, t.tester_profile?.name ?? "Unknown");
|
|
451
|
+
}
|
|
452
|
+
await runLocalSimulations(client, {
|
|
453
|
+
workspaceId: resolvedWorkspace,
|
|
454
|
+
studyId: resolvedStudy,
|
|
455
|
+
iterationId: iterationId,
|
|
456
|
+
testerIds: createdTesters.map((t) => t.id),
|
|
457
|
+
testerNames: testerNameMap,
|
|
458
|
+
url: resolvedOpts.url,
|
|
459
|
+
screenFormat: resolvedOpts.screenFormat,
|
|
460
|
+
locale: opts.locale,
|
|
461
|
+
maxInteractions: opts.maxInteractions ? parseMaxInteractions(opts.maxInteractions) : undefined,
|
|
462
|
+
headed: !!opts.headed,
|
|
463
|
+
slowMo: opts.slowMo ? parseSlowMo(opts.slowMo) : undefined,
|
|
464
|
+
devtools: opts.devtools,
|
|
465
|
+
debug: opts.debug,
|
|
466
|
+
parallel: opts.parallel ? parseInt(opts.parallel, 10) : undefined,
|
|
467
|
+
quiet: globals.quiet,
|
|
468
|
+
json: globals.json,
|
|
469
|
+
});
|
|
470
|
+
if (globals.json) {
|
|
471
|
+
output({
|
|
472
|
+
iteration_id: iterationId,
|
|
473
|
+
testers: createdTesters.map((t) => ({ id: t.id, profile_name: t.tester_profile?.name })),
|
|
474
|
+
mode: "local",
|
|
475
|
+
}, true);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
// Remote mode: delegate to backend
|
|
415
480
|
log(`Starting ${createdTesters.length} simulation${createdTesters.length > 1 ? "s" : ""}...`);
|
|
416
481
|
let simResults;
|
|
417
482
|
if (isMedia) {
|
package/dist/commands/study.js
CHANGED
|
@@ -43,8 +43,8 @@ Examples:
|
|
|
43
43
|
$ speqs study create --name "Newsletter" --modality text --content-type email \\
|
|
44
44
|
--assignments '[{"name":"Read","instructions":"Read this email naturally"}]'
|
|
45
45
|
|
|
46
|
-
# Audio
|
|
47
|
-
$ speqs study create --name "Episode Review" --modality audio --content-type
|
|
46
|
+
# Audio conversation study:
|
|
47
|
+
$ speqs study create --name "Episode Review" --modality audio --content-type conversation \\
|
|
48
48
|
--assignments '[{"name":"Listen","instructions":"Listen and react naturally"}]'
|
|
49
49
|
|
|
50
50
|
# Video ad study:
|
|
@@ -61,11 +61,11 @@ Examples:
|
|
|
61
61
|
--questions '[{"question":"How easy was it?","type":"slider","timing":"after","min":0,"max":10}]'
|
|
62
62
|
|
|
63
63
|
Content types by modality:
|
|
64
|
-
text: narrative, informational,
|
|
65
|
-
video:
|
|
66
|
-
audio: music,
|
|
67
|
-
image: product, infographic,
|
|
68
|
-
document:
|
|
64
|
+
text: narrative, informational, commercial, editorial, reference, email, news
|
|
65
|
+
video: tutorial, documentary, entertainment, review, lifestyle, news, social_post, ad
|
|
66
|
+
audio: music, narration, conversation, speech, soundscape, news, ad
|
|
67
|
+
image: product, photography, infographic, artwork, interface, social_post, ad
|
|
68
|
+
document: deck, presentation, report, brochure, guide`)
|
|
69
69
|
.action(async (opts, cmd) => {
|
|
70
70
|
await withClient(cmd, async (client, globals) => {
|
|
71
71
|
let assignments;
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -24,5 +24,35 @@ export declare class ApiClient {
|
|
|
24
24
|
}): Promise<T>;
|
|
25
25
|
put<T = unknown>(path: string, body?: unknown): Promise<T>;
|
|
26
26
|
del(path: string): Promise<void>;
|
|
27
|
+
localSimInit(body: {
|
|
28
|
+
tester_id: string;
|
|
29
|
+
study_id: string;
|
|
30
|
+
product_id: string;
|
|
31
|
+
iteration_id: string;
|
|
32
|
+
}): Promise<import("./local-sim/types.js").LocalSimInitResponse>;
|
|
33
|
+
localSimStep(body: import("./local-sim/types.js").LocalSimStepRequest): Promise<import("./local-sim/types.js").LocalSimStepResponseRaw>;
|
|
34
|
+
localSimRecord(body: import("./local-sim/types.js").LocalSimRecordRequest): Promise<import("./local-sim/types.js").LocalSimRecordResponse>;
|
|
35
|
+
localSimMatchFrame(body: {
|
|
36
|
+
product_id: string;
|
|
37
|
+
study_id: string;
|
|
38
|
+
screenshot_base64: string;
|
|
39
|
+
screenshot_url?: string;
|
|
40
|
+
location_name: string;
|
|
41
|
+
screen_format?: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
frame_version_id: string;
|
|
44
|
+
}>;
|
|
45
|
+
localSimScreenshotUpload(body: {
|
|
46
|
+
product_id: string;
|
|
47
|
+
screenshot_id: string;
|
|
48
|
+
content_type: string;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
upload_info: {
|
|
51
|
+
signed_upload_url: string;
|
|
52
|
+
file_path: string;
|
|
53
|
+
expires_in_seconds: number;
|
|
54
|
+
};
|
|
55
|
+
screenshot_url: string;
|
|
56
|
+
}>;
|
|
27
57
|
private handleResponse;
|
|
28
58
|
}
|
package/dist/lib/api-client.js
CHANGED
|
@@ -143,6 +143,22 @@ export class ApiClient {
|
|
|
143
143
|
throw new ApiError(res.status, res.statusText, body);
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
+
// --- Local simulation endpoints ---
|
|
147
|
+
async localSimInit(body) {
|
|
148
|
+
return this.post("/simulation/local/init", body, { timeout: 30_000 });
|
|
149
|
+
}
|
|
150
|
+
async localSimStep(body) {
|
|
151
|
+
return this.post("/simulation/local/step", body, { timeout: 60_000 });
|
|
152
|
+
}
|
|
153
|
+
async localSimRecord(body) {
|
|
154
|
+
return this.post("/simulation/local/record", body, { timeout: 60_000 });
|
|
155
|
+
}
|
|
156
|
+
async localSimMatchFrame(body) {
|
|
157
|
+
return this.post("/simulation/local/match-frame", body, { timeout: 30_000 });
|
|
158
|
+
}
|
|
159
|
+
async localSimScreenshotUpload(body) {
|
|
160
|
+
return this.post("/simulation/local/screenshot/upload", body);
|
|
161
|
+
}
|
|
146
162
|
async handleResponse(resp) {
|
|
147
163
|
if (!resp.ok) {
|
|
148
164
|
let body;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action executor — resolves elements and executes Playwright actions.
|
|
3
|
+
*
|
|
4
|
+
* Resolution strategy:
|
|
5
|
+
* 1. CDP node resolution (using node_id from tree data)
|
|
6
|
+
* 2. Playwright locator fallback (using element_name + element_type)
|
|
7
|
+
* 3. Coordinate fallback (if returned by backend)
|
|
8
|
+
*/
|
|
9
|
+
import type { Page } from "playwright-core";
|
|
10
|
+
import type { LocalStepAction, ActionResult, ContextValue, TreeData } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* Execute a single action on the page.
|
|
13
|
+
*/
|
|
14
|
+
export declare function executeAction(page: Page, action: LocalStepAction, treeData: TreeData, contextValues: ContextValue[]): Promise<ActionResult>;
|
|
15
|
+
/**
|
|
16
|
+
* Compare two base64 screenshots to detect visible change.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectNoVisibleChange(before: string, after: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Build a human-readable action description matching backend's format_action_detail().
|
|
21
|
+
*/
|
|
22
|
+
export declare function describeAction(action: LocalStepAction): string;
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Action executor — resolves elements and executes Playwright actions.
|
|
3
|
+
*
|
|
4
|
+
* Resolution strategy:
|
|
5
|
+
* 1. CDP node resolution (using node_id from tree data)
|
|
6
|
+
* 2. Playwright locator fallback (using element_name + element_type)
|
|
7
|
+
* 3. Coordinate fallback (if returned by backend)
|
|
8
|
+
*/
|
|
9
|
+
import { resolveNodeToBoundingBox } from "./browser.js";
|
|
10
|
+
import { isDebugEnabled } from "./debug.js";
|
|
11
|
+
// --- ARIA role → Playwright role mapping ---
|
|
12
|
+
const ELEMENT_TYPE_TO_ROLE = {
|
|
13
|
+
BUTTON: "button",
|
|
14
|
+
TEXT_INPUT: "input",
|
|
15
|
+
SELECT: "combobox",
|
|
16
|
+
LINK: "link",
|
|
17
|
+
CHECKBOX: "checkbox",
|
|
18
|
+
RADIO: "radio",
|
|
19
|
+
HEADING: "heading",
|
|
20
|
+
TAB: "tab",
|
|
21
|
+
MENU_ITEM: "menuitem",
|
|
22
|
+
SWITCH: "switch",
|
|
23
|
+
SLIDER: "slider",
|
|
24
|
+
IMAGE: "img",
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Execute a single action on the page.
|
|
28
|
+
*/
|
|
29
|
+
export async function executeAction(page, action, treeData, contextValues) {
|
|
30
|
+
try {
|
|
31
|
+
// Intercept "back button" taps — the LLM often tries to tap the browser
|
|
32
|
+
// back button which doesn't exist in the viewport. Convert to page.goBack().
|
|
33
|
+
const isBackAttempt = action.element_name?.toLowerCase().includes("back") &&
|
|
34
|
+
(action.element_description?.toLowerCase().includes("browser") ||
|
|
35
|
+
action.element_description?.toLowerCase().includes("navigate") ||
|
|
36
|
+
action.element_name?.toLowerCase().includes("browser back"));
|
|
37
|
+
if (isBackAttempt || action.type === "navigate_back") {
|
|
38
|
+
await page.goBack({ timeout: 10_000 }).catch(() => { });
|
|
39
|
+
return { success: true, elementName: action.element_name, coordinates: null };
|
|
40
|
+
}
|
|
41
|
+
let coordinates = null;
|
|
42
|
+
switch (action.type) {
|
|
43
|
+
case "tap":
|
|
44
|
+
coordinates = await executeTap(page, action, treeData);
|
|
45
|
+
break;
|
|
46
|
+
case "text_input":
|
|
47
|
+
coordinates = await executeTextInput(page, action, treeData, contextValues);
|
|
48
|
+
break;
|
|
49
|
+
case "scroll":
|
|
50
|
+
await executeScroll(page, action, treeData);
|
|
51
|
+
break;
|
|
52
|
+
case "swipe":
|
|
53
|
+
case "pull_to_refresh":
|
|
54
|
+
await executeSwipe(page, action.direction ?? "down");
|
|
55
|
+
break;
|
|
56
|
+
case "wait":
|
|
57
|
+
await page.waitForTimeout(action.duration_ms ?? 1000);
|
|
58
|
+
break;
|
|
59
|
+
case "navigate_back":
|
|
60
|
+
await page.goBack({ timeout: 10_000 }).catch(() => { });
|
|
61
|
+
break;
|
|
62
|
+
case "long_press":
|
|
63
|
+
coordinates = await executeLongPress(page, action, treeData);
|
|
64
|
+
break;
|
|
65
|
+
case "double_tap":
|
|
66
|
+
coordinates = await executeDoubleTap(page, action, treeData);
|
|
67
|
+
break;
|
|
68
|
+
case "drag":
|
|
69
|
+
// Drag requires coordinates — skip if unavailable
|
|
70
|
+
break;
|
|
71
|
+
case "think":
|
|
72
|
+
// No-op: model is reasoning without acting
|
|
73
|
+
break;
|
|
74
|
+
case "pinch_zoom":
|
|
75
|
+
case "rotate_device":
|
|
76
|
+
// Not supported in desktop browser
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return { success: true, elementName: action.element_name, coordinates };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
if (!isRecoverableError(err))
|
|
83
|
+
throw err;
|
|
84
|
+
return { success: false, elementName: action.element_name, coordinates: null };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// --- Element Resolution ---
|
|
88
|
+
/**
|
|
89
|
+
* Resolve an element: try CDP node_id first, then Playwright locator.
|
|
90
|
+
* Returns click coordinates { x, y } or null.
|
|
91
|
+
*/
|
|
92
|
+
async function resolveElement(page, action, treeData) {
|
|
93
|
+
// Strategy 1: CDP node resolution
|
|
94
|
+
if (action.node_id) {
|
|
95
|
+
const box = await resolveNodeToBoundingBox(page, action.node_id, treeData);
|
|
96
|
+
if (box) {
|
|
97
|
+
if (isDebugEnabled())
|
|
98
|
+
console.error(` [resolve] CDP node ${action.node_id} → (${Math.round(box.x)}, ${Math.round(box.y)})`);
|
|
99
|
+
return { x: box.x, y: box.y };
|
|
100
|
+
}
|
|
101
|
+
if (isDebugEnabled())
|
|
102
|
+
console.error(` [resolve] CDP node ${action.node_id} → FAILED, trying Playwright fallback`);
|
|
103
|
+
}
|
|
104
|
+
// Strategy 2: Playwright locator (tries multiple strategies)
|
|
105
|
+
const locator = await findElement(page, action);
|
|
106
|
+
if (locator) {
|
|
107
|
+
const box = await locator.boundingBox();
|
|
108
|
+
if (box) {
|
|
109
|
+
const coords = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
110
|
+
if (isDebugEnabled())
|
|
111
|
+
console.error(` [resolve] Playwright locator → (${Math.round(coords.x)}, ${Math.round(coords.y)})`);
|
|
112
|
+
return coords;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (isDebugEnabled())
|
|
116
|
+
console.error(` [resolve] ALL strategies FAILED for "${action.element_name}"`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Resolve to a Playwright Locator (for fill/type operations that need a Locator).
|
|
121
|
+
*/
|
|
122
|
+
async function resolveLocator(page, action, treeData) {
|
|
123
|
+
return findElement(page, action);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build a Playwright locator, trying multiple strategies in order:
|
|
127
|
+
* 1. getByRole with element_name
|
|
128
|
+
* 2. getByRole with element_description (often more accurate)
|
|
129
|
+
* 3. getByText with element_name
|
|
130
|
+
* 4. getByText with element_description
|
|
131
|
+
*/
|
|
132
|
+
async function findElement(page, action) {
|
|
133
|
+
const role = action.element_type ? ELEMENT_TYPE_TO_ROLE[action.element_type] : null;
|
|
134
|
+
const name = action.element_name;
|
|
135
|
+
const desc = action.element_description;
|
|
136
|
+
const roleArg = role;
|
|
137
|
+
const candidates = [];
|
|
138
|
+
// Strategy 1: role + name
|
|
139
|
+
if (role && name) {
|
|
140
|
+
candidates.push(page.getByRole(roleArg, { name, exact: false }).first());
|
|
141
|
+
}
|
|
142
|
+
// Strategy 2: role + description
|
|
143
|
+
if (role && desc) {
|
|
144
|
+
candidates.push(page.getByRole(roleArg, { name: desc, exact: false }).first());
|
|
145
|
+
}
|
|
146
|
+
// Strategy 3: role only (if there's just one of that role)
|
|
147
|
+
if (role) {
|
|
148
|
+
candidates.push(page.getByRole(roleArg).first());
|
|
149
|
+
}
|
|
150
|
+
// Strategy 4: text search on name
|
|
151
|
+
if (name) {
|
|
152
|
+
candidates.push(page.getByText(name, { exact: false }).first());
|
|
153
|
+
}
|
|
154
|
+
// Strategy 5: text search on description
|
|
155
|
+
if (desc) {
|
|
156
|
+
candidates.push(page.getByText(desc, { exact: false }).first());
|
|
157
|
+
}
|
|
158
|
+
// Strategy 6: link by name (common case — LLM often calls links "buttons")
|
|
159
|
+
if (name) {
|
|
160
|
+
candidates.push(page.getByRole("link", { name, exact: false }).first());
|
|
161
|
+
}
|
|
162
|
+
if (desc) {
|
|
163
|
+
candidates.push(page.getByRole("link", { name: desc, exact: false }).first());
|
|
164
|
+
}
|
|
165
|
+
for (const locator of candidates) {
|
|
166
|
+
try {
|
|
167
|
+
await locator.waitFor({ state: "visible", timeout: 1500 });
|
|
168
|
+
return locator;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// Try next strategy
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
// --- Action Implementations ---
|
|
177
|
+
async function executeTap(page, action, treeData) {
|
|
178
|
+
const count = action.count ?? 1;
|
|
179
|
+
const coords = await resolveElement(page, action, treeData);
|
|
180
|
+
if (coords) {
|
|
181
|
+
for (let i = 0; i < count; i++) {
|
|
182
|
+
await page.mouse.click(coords.x, coords.y);
|
|
183
|
+
}
|
|
184
|
+
return coords;
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
throw new Error(`Cannot locate element for tap: ${action.element_name ?? "unknown"}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function executeTextInput(page, action, treeData, contextValues) {
|
|
191
|
+
// Resolve the actual text to type
|
|
192
|
+
const text = resolveTextValue(action, contextValues);
|
|
193
|
+
// Try to get a Playwright locator for fill operations
|
|
194
|
+
const locator = await resolveLocator(page, action, treeData);
|
|
195
|
+
if (locator) {
|
|
196
|
+
if (action.mode === "click_type") {
|
|
197
|
+
await locator.click({ timeout: 5000 });
|
|
198
|
+
await locator.fill("");
|
|
199
|
+
await locator.pressSequentially(text, { delay: 30 });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
await locator.fill(text);
|
|
203
|
+
}
|
|
204
|
+
if (action.submit) {
|
|
205
|
+
await locator.press("Enter");
|
|
206
|
+
}
|
|
207
|
+
// Extract coordinates from the locator for recording
|
|
208
|
+
const box = await locator.boundingBox().catch(() => null);
|
|
209
|
+
return box ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } : null;
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Coordinate-based fallback
|
|
213
|
+
const coords = await resolveElement(page, action, treeData);
|
|
214
|
+
if (coords) {
|
|
215
|
+
await page.mouse.click(coords.x, coords.y);
|
|
216
|
+
await page.waitForTimeout(200);
|
|
217
|
+
const selectAll = process.platform === "darwin" ? "Meta+a" : "Control+a";
|
|
218
|
+
await page.keyboard.press(selectAll);
|
|
219
|
+
await page.keyboard.type(text, { delay: 30 });
|
|
220
|
+
if (action.submit) {
|
|
221
|
+
await page.keyboard.press("Enter");
|
|
222
|
+
}
|
|
223
|
+
return coords;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
throw new Error(`Cannot locate element for text input: ${action.element_name ?? "unknown"}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function executeScroll(page, action, treeData) {
|
|
231
|
+
const viewport = page.viewportSize() ?? { width: 1440, height: 900 };
|
|
232
|
+
const amountMap = {
|
|
233
|
+
small: 0.5, medium: 0.8, large: 1.5, extra_large: 3.0,
|
|
234
|
+
};
|
|
235
|
+
const fraction = amountMap[action.amount ?? "medium"] ?? 0.8;
|
|
236
|
+
const pixels = Math.round(viewport.height * fraction);
|
|
237
|
+
switch (action.direction) {
|
|
238
|
+
case "up":
|
|
239
|
+
await page.evaluate((px) => window.scrollBy(0, -px), pixels);
|
|
240
|
+
break;
|
|
241
|
+
case "down":
|
|
242
|
+
await page.evaluate((px) => window.scrollBy(0, px), pixels);
|
|
243
|
+
break;
|
|
244
|
+
case "to_top":
|
|
245
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
246
|
+
break;
|
|
247
|
+
case "to_bottom":
|
|
248
|
+
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight));
|
|
249
|
+
break;
|
|
250
|
+
case "to_element": {
|
|
251
|
+
const locator = await findElement(page, action);
|
|
252
|
+
if (locator) {
|
|
253
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => { });
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
await page.evaluate((px) => window.scrollBy(0, px), pixels);
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
default:
|
|
261
|
+
await page.evaluate((px) => window.scrollBy(0, px), pixels);
|
|
262
|
+
}
|
|
263
|
+
await page.waitForTimeout(300);
|
|
264
|
+
}
|
|
265
|
+
async function executeSwipe(page, direction) {
|
|
266
|
+
const viewport = page.viewportSize() ?? { width: 1440, height: 900 };
|
|
267
|
+
const cx = viewport.width / 2;
|
|
268
|
+
const cy = viewport.height / 2;
|
|
269
|
+
const d = viewport.height * 0.4;
|
|
270
|
+
let sx = cx, sy = cy, ex = cx, ey = cy;
|
|
271
|
+
switch (direction) {
|
|
272
|
+
case "up":
|
|
273
|
+
sy = cy + d / 2;
|
|
274
|
+
ey = cy - d / 2;
|
|
275
|
+
break;
|
|
276
|
+
case "down":
|
|
277
|
+
sy = cy - d / 2;
|
|
278
|
+
ey = cy + d / 2;
|
|
279
|
+
break;
|
|
280
|
+
case "left":
|
|
281
|
+
sx = cx + d / 2;
|
|
282
|
+
ex = cx - d / 2;
|
|
283
|
+
break;
|
|
284
|
+
case "right":
|
|
285
|
+
sx = cx - d / 2;
|
|
286
|
+
ex = cx + d / 2;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
await page.mouse.move(sx, sy);
|
|
290
|
+
await page.mouse.down();
|
|
291
|
+
await page.mouse.move(ex, ey, { steps: 10 });
|
|
292
|
+
await page.mouse.up();
|
|
293
|
+
}
|
|
294
|
+
async function executeLongPress(page, action, treeData) {
|
|
295
|
+
const coords = await resolveElement(page, action, treeData);
|
|
296
|
+
if (!coords)
|
|
297
|
+
throw new Error(`Cannot locate element for long press: ${action.element_name ?? "unknown"}`);
|
|
298
|
+
await page.mouse.move(coords.x, coords.y);
|
|
299
|
+
await page.mouse.down();
|
|
300
|
+
await page.waitForTimeout(action.duration_ms ?? 500);
|
|
301
|
+
await page.mouse.up();
|
|
302
|
+
return coords;
|
|
303
|
+
}
|
|
304
|
+
async function executeDoubleTap(page, action, treeData) {
|
|
305
|
+
const coords = await resolveElement(page, action, treeData);
|
|
306
|
+
if (coords) {
|
|
307
|
+
await page.mouse.dblclick(coords.x, coords.y);
|
|
308
|
+
return coords;
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
throw new Error(`Cannot locate element for double tap: ${action.element_name ?? "unknown"}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// --- Helpers ---
|
|
315
|
+
/**
|
|
316
|
+
* Resolve the actual text to type from an action, handling var/secret value types.
|
|
317
|
+
*/
|
|
318
|
+
function resolveTextValue(action, contextValues) {
|
|
319
|
+
if (action.value_type === "var" || action.value_type === "secret") {
|
|
320
|
+
const cv = contextValues.find(v => v.name === action.value);
|
|
321
|
+
if (cv?.value)
|
|
322
|
+
return cv.value;
|
|
323
|
+
// Fallback to the key name if resolution fails
|
|
324
|
+
return action.value ?? "";
|
|
325
|
+
}
|
|
326
|
+
return action.value ?? "";
|
|
327
|
+
}
|
|
328
|
+
function isRecoverableError(err) {
|
|
329
|
+
if (err instanceof Error && err.constructor.name === "TargetClosedError")
|
|
330
|
+
return false;
|
|
331
|
+
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
|
332
|
+
const fatal = ["target page", "browser has been closed", "target closed", "browser disconnected"];
|
|
333
|
+
return !fatal.some((f) => msg.includes(f));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Compare two base64 screenshots to detect visible change.
|
|
337
|
+
*/
|
|
338
|
+
export function detectNoVisibleChange(before, after) {
|
|
339
|
+
return before === after;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Build a human-readable action description matching backend's format_action_detail().
|
|
343
|
+
*/
|
|
344
|
+
export function describeAction(action) {
|
|
345
|
+
const element = action.element_name || "element";
|
|
346
|
+
switch (action.type) {
|
|
347
|
+
case "tap":
|
|
348
|
+
return action.count && action.count > 1
|
|
349
|
+
? `tap on '${element}' x${action.count}`
|
|
350
|
+
: `tap on '${element}'`;
|
|
351
|
+
case "text_input": {
|
|
352
|
+
const val = action.value_type === "secret" ? "***" : `"${(action.value ?? "").slice(0, 30)}"`;
|
|
353
|
+
const modeStr = action.mode ? ` (${action.mode}${action.submit ? ", submit" : ""})` : "";
|
|
354
|
+
return `text_input on '${element}' → ${val}${modeStr}`;
|
|
355
|
+
}
|
|
356
|
+
case "scroll":
|
|
357
|
+
return action.direction === "to_element"
|
|
358
|
+
? `scroll to '${element}'`
|
|
359
|
+
: `scroll ${action.direction ?? "down"} (${action.amount ?? "medium"})`;
|
|
360
|
+
case "swipe":
|
|
361
|
+
return `swipe ${action.direction ?? "up"} on '${element}'`;
|
|
362
|
+
case "wait":
|
|
363
|
+
return `wait ${action.duration_ms ?? 1000}ms`;
|
|
364
|
+
case "navigate_back":
|
|
365
|
+
return "navigate back";
|
|
366
|
+
case "long_press":
|
|
367
|
+
return `long_press on '${element}'`;
|
|
368
|
+
case "double_tap":
|
|
369
|
+
return `double_tap on '${element}'`;
|
|
370
|
+
case "drag":
|
|
371
|
+
return `drag '${element}'`;
|
|
372
|
+
case "think":
|
|
373
|
+
return `think: "${(action.thoughts ?? "").slice(0, 50)}"`;
|
|
374
|
+
case "pull_to_refresh":
|
|
375
|
+
return "pull_to_refresh";
|
|
376
|
+
default:
|
|
377
|
+
return `${action.type} on '${element}'`;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playwright browser lifecycle, CDP tree extraction, and node resolution.
|
|
3
|
+
*
|
|
4
|
+
* Tree format matches backend's get_accessibility_tree():
|
|
5
|
+
* [frame_index:node_id] role "name"
|
|
6
|
+
* with single-space indentation per level.
|
|
7
|
+
*/
|
|
8
|
+
import { type Browser, type BrowserContext, type Page } from "playwright-core";
|
|
9
|
+
import type { LocalSimBrowserOptions, TreeData } from "./types.js";
|
|
10
|
+
import "./install.js";
|
|
11
|
+
export interface BrowserSession {
|
|
12
|
+
browser: Browser;
|
|
13
|
+
context: BrowserContext;
|
|
14
|
+
page: Page;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Launch a shared browser process with a pre-configured context.
|
|
18
|
+
* Create tabs with createTab() — they appear as tabs in one window.
|
|
19
|
+
*/
|
|
20
|
+
export declare function launchSharedBrowser(opts: LocalSimBrowserOptions): Promise<Browser>;
|
|
21
|
+
/**
|
|
22
|
+
* Create a new tab in the shared browser's default context.
|
|
23
|
+
* Tabs share cookies/storage — appears as tabs in one window in headed mode.
|
|
24
|
+
*/
|
|
25
|
+
export declare function createTab(browser: Browser, opts: LocalSimBrowserOptions): Promise<BrowserSession>;
|
|
26
|
+
/**
|
|
27
|
+
* Launch a standalone browser session (single tester, owns the browser).
|
|
28
|
+
*/
|
|
29
|
+
export declare function launchBrowser(opts: LocalSimBrowserOptions): Promise<BrowserSession>;
|
|
30
|
+
export interface ObservationData {
|
|
31
|
+
screenshot: string;
|
|
32
|
+
treeData: TreeData;
|
|
33
|
+
url: string;
|
|
34
|
+
viewportWidth: number;
|
|
35
|
+
viewportHeight: number;
|
|
36
|
+
scrollPosition: number;
|
|
37
|
+
documentHeight: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Capture a full observation from the current page state.
|
|
41
|
+
*/
|
|
42
|
+
export declare function captureObservation(page: Page): Promise<ObservationData>;
|
|
43
|
+
/**
|
|
44
|
+
* Extract accessibility tree via CDP, matching backend's [nodeId] role "name" format.
|
|
45
|
+
*/
|
|
46
|
+
export declare function extractAccessibilityTree(page: Page): Promise<TreeData>;
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a composite node_id (e.g., "0:42") to bounding box coordinates.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveNodeToBoundingBox(page: Page, nodeId: string, treeData: TreeData): Promise<{
|
|
51
|
+
x: number;
|
|
52
|
+
y: number;
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
} | null>;
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a composite node_id to an XPath selector.
|
|
58
|
+
*/
|
|
59
|
+
export declare function resolveNodeToXPath(page: Page, nodeId: string, treeData: TreeData): Promise<string | null>;
|
|
60
|
+
export declare function takeScreenshot(page: Page): Promise<string>;
|
|
61
|
+
export declare function takeScreenshotJpeg(page: Page, quality?: number): Promise<Buffer>;
|
|
62
|
+
export declare function navigateWithRetry(page: Page, url: string, maxRetries?: number): Promise<void>;
|
|
63
|
+
export declare function closeBrowser(session: BrowserSession): Promise<void>;
|