speqs 0.5.1 → 0.7.0
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/config.js +13 -4
- package/dist/commands/iteration.js +64 -13
- package/dist/commands/simulation.d.ts +1 -1
- package/dist/commands/simulation.js +454 -121
- package/dist/commands/study.js +140 -27
- package/dist/commands/tester-profile.js +17 -4
- package/dist/commands/tester.js +12 -3
- package/dist/commands/workspace.js +51 -6
- package/dist/config.d.ts +2 -0
- package/dist/index.js +3 -1
- package/dist/lib/alias-store.d.ts +22 -3
- package/dist/lib/alias-store.js +60 -12
- package/dist/lib/api-client.d.ts +31 -0
- package/dist/lib/api-client.js +83 -27
- package/dist/lib/auth.js +4 -1
- package/dist/lib/command-helpers.d.ts +4 -0
- package/dist/lib/command-helpers.js +41 -4
- 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/output.d.ts +16 -1
- package/dist/lib/output.js +250 -61
- package/dist/lib/types.d.ts +7 -30
- package/dist/lib/types.js +9 -1
- package/dist/lib/upload.d.ts +47 -0
- package/dist/lib/upload.js +178 -0
- package/package.json +3 -2
|
@@ -0,0 +1,332 @@
|
|
|
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 { chromium } from "playwright-core";
|
|
9
|
+
// Import install module for side-effect: sets PLAYWRIGHT_BROWSERS_PATH
|
|
10
|
+
import "./install.js";
|
|
11
|
+
// Viewport presets matching backend (app/simulation/computers/config.py)
|
|
12
|
+
const VIEWPORT_PRESETS = {
|
|
13
|
+
desktop: { width: 1440, height: 900 },
|
|
14
|
+
mobile_portrait: { width: 393, height: 852 },
|
|
15
|
+
};
|
|
16
|
+
const MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " +
|
|
17
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) " +
|
|
18
|
+
"Version/17.0 Mobile/15E148 Safari/604.1";
|
|
19
|
+
const NODE_NAME_MAX_LENGTH = 50;
|
|
20
|
+
/**
|
|
21
|
+
* Launch a shared browser process with a pre-configured context.
|
|
22
|
+
* Create tabs with createTab() — they appear as tabs in one window.
|
|
23
|
+
*/
|
|
24
|
+
export async function launchSharedBrowser(opts) {
|
|
25
|
+
const viewport = opts.viewport ?? VIEWPORT_PRESETS[opts.screenFormat] ?? VIEWPORT_PRESETS.desktop;
|
|
26
|
+
const isMobile = opts.screenFormat === "mobile_portrait";
|
|
27
|
+
const browser = await chromium.launch({
|
|
28
|
+
headless: !opts.headed,
|
|
29
|
+
...(opts.slowMo && { slowMo: opts.slowMo }),
|
|
30
|
+
args: [
|
|
31
|
+
"--disable-blink-features=AutomationControlled",
|
|
32
|
+
"--disable-features=IsolateOrigins,site-per-process",
|
|
33
|
+
"--no-first-run",
|
|
34
|
+
"--no-default-browser-check",
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
// Create the default context so all tabs share it
|
|
38
|
+
await browser.newContext({
|
|
39
|
+
viewport,
|
|
40
|
+
...(isMobile && {
|
|
41
|
+
userAgent: MOBILE_USER_AGENT,
|
|
42
|
+
deviceScaleFactor: 2,
|
|
43
|
+
isMobile: true,
|
|
44
|
+
hasTouch: true,
|
|
45
|
+
}),
|
|
46
|
+
...(opts.locale && { locale: opts.locale }),
|
|
47
|
+
});
|
|
48
|
+
return browser;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Create a new tab in the shared browser's default context.
|
|
52
|
+
* Tabs share cookies/storage — appears as tabs in one window in headed mode.
|
|
53
|
+
*/
|
|
54
|
+
export async function createTab(browser, opts) {
|
|
55
|
+
// Reuse the first context (default) so tabs appear in the same window
|
|
56
|
+
const contexts = browser.contexts();
|
|
57
|
+
let context;
|
|
58
|
+
if (contexts.length > 0) {
|
|
59
|
+
context = contexts[0];
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
const viewport = opts.viewport ?? VIEWPORT_PRESETS[opts.screenFormat] ?? VIEWPORT_PRESETS.desktop;
|
|
63
|
+
const isMobile = opts.screenFormat === "mobile_portrait";
|
|
64
|
+
context = await browser.newContext({
|
|
65
|
+
viewport,
|
|
66
|
+
...(isMobile && {
|
|
67
|
+
userAgent: MOBILE_USER_AGENT,
|
|
68
|
+
deviceScaleFactor: 2,
|
|
69
|
+
isMobile: true,
|
|
70
|
+
hasTouch: true,
|
|
71
|
+
}),
|
|
72
|
+
...(opts.locale && { locale: opts.locale }),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const page = await context.newPage();
|
|
76
|
+
page.setDefaultNavigationTimeout(30_000);
|
|
77
|
+
page.setDefaultTimeout(10_000);
|
|
78
|
+
return { browser, context, page };
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Launch a standalone browser session (single tester, owns the browser).
|
|
82
|
+
*/
|
|
83
|
+
export async function launchBrowser(opts) {
|
|
84
|
+
const browser = await launchSharedBrowser(opts);
|
|
85
|
+
return createTab(browser, opts);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Capture a full observation from the current page state.
|
|
89
|
+
*/
|
|
90
|
+
export async function captureObservation(page) {
|
|
91
|
+
await page.waitForLoadState("networkidle").catch(() => { });
|
|
92
|
+
const screenshotBuffer = await page.screenshot({ type: "png" });
|
|
93
|
+
const screenshot = screenshotBuffer.toString("base64");
|
|
94
|
+
const treeData = await extractAccessibilityTree(page);
|
|
95
|
+
const url = page.url();
|
|
96
|
+
const viewportSize = page.viewportSize() ?? { width: 1440, height: 900 };
|
|
97
|
+
const { scrollY, docHeight } = await page.evaluate(() => ({
|
|
98
|
+
scrollY: Math.round(window.scrollY),
|
|
99
|
+
docHeight: document.documentElement.scrollHeight,
|
|
100
|
+
}));
|
|
101
|
+
return {
|
|
102
|
+
screenshot,
|
|
103
|
+
treeData,
|
|
104
|
+
url,
|
|
105
|
+
viewportWidth: viewportSize.width,
|
|
106
|
+
viewportHeight: viewportSize.height,
|
|
107
|
+
scrollPosition: scrollY,
|
|
108
|
+
documentHeight: docHeight,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Extract accessibility tree via CDP, matching backend's [nodeId] role "name" format.
|
|
113
|
+
*/
|
|
114
|
+
export async function extractAccessibilityTree(page) {
|
|
115
|
+
const nodeMap = new Map();
|
|
116
|
+
const allNodes = new Map();
|
|
117
|
+
let rootId = null;
|
|
118
|
+
const frames = page.frames();
|
|
119
|
+
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
|
|
120
|
+
const frame = frames[frameIndex];
|
|
121
|
+
let cdp;
|
|
122
|
+
try {
|
|
123
|
+
cdp = await frame.page().context().newCDPSession(frame.page());
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const result = await cdp.send("Accessibility.getFullAXTree");
|
|
130
|
+
const nodes = result.nodes || [];
|
|
131
|
+
for (const node of nodes) {
|
|
132
|
+
const compositeId = `${frameIndex}:${node.nodeId}`;
|
|
133
|
+
const parentCompositeId = node.parentId ? `${frameIndex}:${node.parentId}` : null;
|
|
134
|
+
allNodes.set(compositeId, {
|
|
135
|
+
...node,
|
|
136
|
+
compositeId,
|
|
137
|
+
children: (node.childIds || []).map(c => `${frameIndex}:${c}`),
|
|
138
|
+
});
|
|
139
|
+
if (node.backendDOMNodeId) {
|
|
140
|
+
nodeMap.set(compositeId, {
|
|
141
|
+
backendNodeId: node.backendDOMNodeId,
|
|
142
|
+
frameIndex,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
// Track the root (first node without a parent in frame 0)
|
|
146
|
+
if (frameIndex === 0 && !parentCompositeId && !rootId) {
|
|
147
|
+
rootId = compositeId;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Frame may have detached
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await cdp.detach().catch(() => { });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// Build simplified text
|
|
159
|
+
const lines = [];
|
|
160
|
+
function formatNode(id, depth) {
|
|
161
|
+
const node = allNodes.get(id);
|
|
162
|
+
if (!node)
|
|
163
|
+
return;
|
|
164
|
+
const role = node.role?.value || "";
|
|
165
|
+
const name = node.name?.value || "";
|
|
166
|
+
const ignored = node.ignored ?? false;
|
|
167
|
+
// Skip ignored nodes without meaningful children
|
|
168
|
+
const isStructural = ["generic", "none", "presentation", "InlineTextBox"].includes(role);
|
|
169
|
+
if (ignored || isStructural) {
|
|
170
|
+
// Still traverse children
|
|
171
|
+
for (const childId of node.children) {
|
|
172
|
+
formatNode(childId, depth);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Skip nodes with no role or only whitespace name
|
|
177
|
+
if (!role || role === "RootWebArea" && depth > 0) {
|
|
178
|
+
for (const childId of node.children) {
|
|
179
|
+
formatNode(childId, depth);
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const indent = " ".repeat(depth);
|
|
184
|
+
const truncatedName = name.length > NODE_NAME_MAX_LENGTH
|
|
185
|
+
? name.slice(0, NODE_NAME_MAX_LENGTH) + "..."
|
|
186
|
+
: name;
|
|
187
|
+
const nameStr = truncatedName ? ` "${truncatedName}"` : "";
|
|
188
|
+
lines.push(`${indent}[${id}] ${role}${nameStr}`);
|
|
189
|
+
// Show grouping marker for many children
|
|
190
|
+
if (node.children.length >= 3) {
|
|
191
|
+
lines.push(`${indent} --- ${node.children.length} children ---`);
|
|
192
|
+
}
|
|
193
|
+
for (const childId of node.children) {
|
|
194
|
+
formatNode(childId, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (rootId) {
|
|
198
|
+
formatNode(rootId, 0);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
simplified: lines.join("\n"),
|
|
202
|
+
nodeMap,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
// --- Node Resolution via CDP ---
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a composite node_id (e.g., "0:42") to bounding box coordinates.
|
|
208
|
+
*/
|
|
209
|
+
export async function resolveNodeToBoundingBox(page, nodeId, treeData) {
|
|
210
|
+
const nodeInfo = treeData.nodeMap.get(nodeId);
|
|
211
|
+
if (!nodeInfo)
|
|
212
|
+
return null;
|
|
213
|
+
let cdp;
|
|
214
|
+
try {
|
|
215
|
+
cdp = await page.context().newCDPSession(page);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
// Resolve backend DOM node to a JS object
|
|
222
|
+
const domResult = await cdp.send("DOM.resolveNode", {
|
|
223
|
+
backendNodeId: nodeInfo.backendNodeId,
|
|
224
|
+
});
|
|
225
|
+
const objectId = domResult?.object?.objectId;
|
|
226
|
+
if (!objectId)
|
|
227
|
+
return null;
|
|
228
|
+
// Get bounding box via JS — walk up to parent if node has zero size
|
|
229
|
+
// (text nodes and inline elements often have no bounding rect)
|
|
230
|
+
const boxResult = await cdp.send("Runtime.callFunctionOn", {
|
|
231
|
+
objectId,
|
|
232
|
+
functionDeclaration: `function() {
|
|
233
|
+
let el = this;
|
|
234
|
+
for (let i = 0; i < 5; i++) {
|
|
235
|
+
const r = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
|
|
236
|
+
if (r && r.width > 0 && r.height > 0) {
|
|
237
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width, height: r.height };
|
|
238
|
+
}
|
|
239
|
+
if (!el.parentElement) break;
|
|
240
|
+
el = el.parentElement;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}`,
|
|
244
|
+
returnByValue: true,
|
|
245
|
+
});
|
|
246
|
+
return boxResult?.result?.value ?? null;
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
await cdp.detach().catch(() => { });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Resolve a composite node_id to an XPath selector.
|
|
257
|
+
*/
|
|
258
|
+
export async function resolveNodeToXPath(page, nodeId, treeData) {
|
|
259
|
+
const nodeInfo = treeData.nodeMap.get(nodeId);
|
|
260
|
+
if (!nodeInfo)
|
|
261
|
+
return null;
|
|
262
|
+
let cdp;
|
|
263
|
+
try {
|
|
264
|
+
cdp = await page.context().newCDPSession(page);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
const domResult = await cdp.send("DOM.resolveNode", {
|
|
271
|
+
backendNodeId: nodeInfo.backendNodeId,
|
|
272
|
+
});
|
|
273
|
+
const objectId = domResult?.object?.objectId;
|
|
274
|
+
if (!objectId)
|
|
275
|
+
return null;
|
|
276
|
+
const xpathResult = await cdp.send("Runtime.callFunctionOn", {
|
|
277
|
+
objectId,
|
|
278
|
+
functionDeclaration: `function() {
|
|
279
|
+
function getXPath(node) {
|
|
280
|
+
if (!node.parentNode) return '';
|
|
281
|
+
if (node === document.body) return '/html/body';
|
|
282
|
+
const siblings = Array.from(node.parentNode.children).filter(c => c.tagName === node.tagName);
|
|
283
|
+
const index = siblings.indexOf(node) + 1;
|
|
284
|
+
const tag = node.tagName.toLowerCase();
|
|
285
|
+
const suffix = siblings.length > 1 ? '[' + index + ']' : '';
|
|
286
|
+
return getXPath(node.parentNode) + '/' + tag + suffix;
|
|
287
|
+
}
|
|
288
|
+
return getXPath(this);
|
|
289
|
+
}`,
|
|
290
|
+
returnByValue: true,
|
|
291
|
+
});
|
|
292
|
+
return xpathResult?.result?.value || null;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await cdp.detach().catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// --- Utilities ---
|
|
302
|
+
export async function takeScreenshot(page) {
|
|
303
|
+
const buffer = await page.screenshot({ type: "png" });
|
|
304
|
+
return buffer.toString("base64");
|
|
305
|
+
}
|
|
306
|
+
export async function takeScreenshotJpeg(page, quality = 85) {
|
|
307
|
+
return page.screenshot({ type: "jpeg", quality });
|
|
308
|
+
}
|
|
309
|
+
export async function navigateWithRetry(page, url, maxRetries = 3) {
|
|
310
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
311
|
+
try {
|
|
312
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
if (attempt === maxRetries) {
|
|
317
|
+
throw new Error(`Navigation failed after ${maxRetries} attempts: ${url}`);
|
|
318
|
+
}
|
|
319
|
+
await page.waitForTimeout(Math.min(3000 * attempt, 8000));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
export async function closeBrowser(session) {
|
|
324
|
+
try {
|
|
325
|
+
await session.context.close();
|
|
326
|
+
}
|
|
327
|
+
catch { }
|
|
328
|
+
try {
|
|
329
|
+
await session.browser.close();
|
|
330
|
+
}
|
|
331
|
+
catch { }
|
|
332
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug HTML report generator for local simulations.
|
|
3
|
+
*
|
|
4
|
+
* Produces a self-contained HTML file with inline screenshots,
|
|
5
|
+
* coordinate overlays, and step-by-step details.
|
|
6
|
+
*/
|
|
7
|
+
import type { DebugStep } from "./loop.js";
|
|
8
|
+
export type { DebugStep };
|
|
9
|
+
export interface DebugReportMeta {
|
|
10
|
+
testerId: string;
|
|
11
|
+
testerName: string;
|
|
12
|
+
url: string;
|
|
13
|
+
screenFormat: string;
|
|
14
|
+
finalStatus: string;
|
|
15
|
+
assignmentStatuses: Array<{
|
|
16
|
+
assignment_id: string;
|
|
17
|
+
status: string;
|
|
18
|
+
step_count: number;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare function generateDebugReport(steps: DebugStep[], meta: DebugReportMeta): void;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug HTML report generator for local simulations.
|
|
3
|
+
*
|
|
4
|
+
* Produces a self-contained HTML file with inline screenshots,
|
|
5
|
+
* coordinate overlays, and step-by-step details.
|
|
6
|
+
*/
|
|
7
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
function escapeHtml(s) {
|
|
11
|
+
return s
|
|
12
|
+
.replace(/&/g, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """);
|
|
16
|
+
}
|
|
17
|
+
const SENTIMENT_COLORS = {
|
|
18
|
+
Positive: "#4caf50",
|
|
19
|
+
Negative: "#f44336",
|
|
20
|
+
Neutral: "#9e9e9e",
|
|
21
|
+
Confused: "#ff9800",
|
|
22
|
+
Frustrated: "#f44336",
|
|
23
|
+
Uncertain: "#ff9800",
|
|
24
|
+
Delighted: "#4caf50",
|
|
25
|
+
Confident: "#2196f3",
|
|
26
|
+
};
|
|
27
|
+
export function generateDebugReport(steps, meta) {
|
|
28
|
+
const dir = join(homedir(), ".speqs", "debug");
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
31
|
+
const shortId = meta.testerId.slice(0, 12);
|
|
32
|
+
const fileName = `sim-${shortId}-${ts}.html`;
|
|
33
|
+
const filePath = join(dir, fileName);
|
|
34
|
+
const totalSteps = steps.length;
|
|
35
|
+
const assignmentHtml = meta.assignmentStatuses
|
|
36
|
+
.map((a) => {
|
|
37
|
+
const color = a.status === "completed" ? "#4caf50" : a.status === "cancelled" ? "#f44336" : "#ff9800";
|
|
38
|
+
return `<span class="badge" style="background:${color}">${escapeHtml(a.status)} (${a.step_count} steps)</span>`;
|
|
39
|
+
})
|
|
40
|
+
.join(" ");
|
|
41
|
+
let stepsHtml = "";
|
|
42
|
+
for (const step of steps) {
|
|
43
|
+
const sentimentColor = SENTIMENT_COLORS[step.sentiment.label] ?? "#9e9e9e";
|
|
44
|
+
// Coordinate dot overlays
|
|
45
|
+
let dotsHtml = "";
|
|
46
|
+
for (let i = 0; i < step.actions.length; i++) {
|
|
47
|
+
const a = step.actions[i];
|
|
48
|
+
if (a.normalizedCoordinates) {
|
|
49
|
+
const leftPct = a.normalizedCoordinates.x / 10;
|
|
50
|
+
const topPct = a.normalizedCoordinates.y / 10;
|
|
51
|
+
const dotColor = a.success ? "#ff4444" : "#ff9800";
|
|
52
|
+
dotsHtml += `<div class="coord-dot" style="left:${leftPct}%;top:${topPct}%;background:${dotColor};" title="${escapeHtml(a.description)}">${i + 1}</div>\n`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Action details
|
|
56
|
+
let actionsHtml = "";
|
|
57
|
+
for (let i = 0; i < step.actions.length; i++) {
|
|
58
|
+
const a = step.actions[i];
|
|
59
|
+
const statusClass = a.success ? "action-ok" : "action-fail";
|
|
60
|
+
const px = a.pixelCoordinates ? `(${Math.round(a.pixelCoordinates.x)}, ${Math.round(a.pixelCoordinates.y)})` : "none";
|
|
61
|
+
const norm = a.normalizedCoordinates ? `(${a.normalizedCoordinates.x}, ${a.normalizedCoordinates.y})` : "none";
|
|
62
|
+
actionsHtml += `
|
|
63
|
+
<div class="action ${statusClass}">
|
|
64
|
+
<strong>${i + 1}. ${escapeHtml(a.type)}</strong> ${a.elementName ? `on <em>${escapeHtml(a.elementName)}</em>` : ""}
|
|
65
|
+
${!a.success ? '<span class="fail-badge">FAILED</span>' : ""}
|
|
66
|
+
<div class="action-meta">
|
|
67
|
+
<span>${escapeHtml(a.description)}</span><br/>
|
|
68
|
+
<span>Pixel: ${px} | Normalized: ${norm}</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>`;
|
|
71
|
+
}
|
|
72
|
+
const afterHtml = step.postActionScreenshotBase64
|
|
73
|
+
? `<div class="screenshot-panel">
|
|
74
|
+
<div class="screenshot-label">After</div>
|
|
75
|
+
<div class="screenshot-container">
|
|
76
|
+
<img src="data:image/jpeg;base64,${step.postActionScreenshotBase64}" alt="Step ${step.step} after" />
|
|
77
|
+
</div>
|
|
78
|
+
</div>`
|
|
79
|
+
: "";
|
|
80
|
+
stepsHtml += `
|
|
81
|
+
<div class="step-card">
|
|
82
|
+
<div class="step-header">
|
|
83
|
+
<h3>Step ${step.step}</h3>
|
|
84
|
+
<span class="assignment-label">${escapeHtml(step.assignmentName)}</span>
|
|
85
|
+
<span class="sentiment" style="color:${sentimentColor}">${escapeHtml(step.sentiment.label)}</span>
|
|
86
|
+
${step.assignmentCompleted ? '<span class="badge" style="background:#4caf50">COMPLETED</span>' : ""}
|
|
87
|
+
</div>
|
|
88
|
+
<div class="step-body">
|
|
89
|
+
<div class="screenshots">
|
|
90
|
+
<div class="screenshot-panel">
|
|
91
|
+
<div class="screenshot-label">Before (observation)</div>
|
|
92
|
+
<div class="screenshot-container">
|
|
93
|
+
<img src="data:image/jpeg;base64,${step.screenshotBase64}" alt="Step ${step.step} before" />
|
|
94
|
+
${dotsHtml}
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
${afterHtml}
|
|
98
|
+
</div>
|
|
99
|
+
<div class="details">
|
|
100
|
+
<div class="detail-section">
|
|
101
|
+
<h4>Actions</h4>
|
|
102
|
+
${actionsHtml}
|
|
103
|
+
</div>
|
|
104
|
+
<div class="detail-section">
|
|
105
|
+
<h4>Comment</h4>
|
|
106
|
+
<p class="comment">${step.comment ? escapeHtml(step.comment) : "<em>none</em>"}</p>
|
|
107
|
+
</div>
|
|
108
|
+
<div class="detail-row">
|
|
109
|
+
<span><strong>Location:</strong> ${step.currentLocation ? escapeHtml(step.currentLocation) : "unknown"}</span>
|
|
110
|
+
<span><strong>URL:</strong> ${escapeHtml(step.url)}</span>
|
|
111
|
+
<span><strong>Effort:</strong> ${step.effortSeconds.toFixed(1)}s</span>
|
|
112
|
+
<span><strong>Sentiment:</strong> v=${step.sentiment.valence.toFixed(1)} i=${step.sentiment.intensity.toFixed(1)}</span>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
const html = `<!DOCTYPE html>
|
|
119
|
+
<html lang="en">
|
|
120
|
+
<head>
|
|
121
|
+
<meta charset="utf-8"/>
|
|
122
|
+
<title>Local Sim Debug — ${escapeHtml(meta.testerName)}</title>
|
|
123
|
+
<style>
|
|
124
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
125
|
+
body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; padding: 20px; }
|
|
126
|
+
h1 { color: #fff; margin-bottom: 8px; }
|
|
127
|
+
h3 { color: #fff; }
|
|
128
|
+
h4 { color: #aaa; margin-bottom: 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
129
|
+
a { color: #64b5f6; }
|
|
130
|
+
.header { margin-bottom: 24px; padding: 16px; background: #16213e; border-radius: 8px; }
|
|
131
|
+
.header-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; font-size: 14px; color: #aaa; }
|
|
132
|
+
.header-meta span { white-space: nowrap; }
|
|
133
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; color: #fff; font-weight: 600; }
|
|
134
|
+
.step-card { margin-bottom: 24px; background: #16213e; border-radius: 8px; overflow: hidden; border: 1px solid #2a2a4a; }
|
|
135
|
+
.step-header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #1a1a3e; border-bottom: 1px solid #2a2a4a; }
|
|
136
|
+
.step-header h3 { font-size: 16px; min-width: 70px; }
|
|
137
|
+
.assignment-label { font-size: 13px; color: #aaa; }
|
|
138
|
+
.sentiment { font-size: 14px; font-weight: 600; margin-left: auto; }
|
|
139
|
+
.step-body { display: flex; gap: 16px; padding: 16px; }
|
|
140
|
+
.screenshots { display: flex; gap: 8px; flex-shrink: 0; max-width: 900px; }
|
|
141
|
+
.screenshot-panel { flex: 1; min-width: 0; }
|
|
142
|
+
.screenshot-label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
|
143
|
+
.screenshot-container { position: relative; display: inline-block; width: 100%; }
|
|
144
|
+
.screenshot-container img { width: 100%; display: block; border-radius: 4px; }
|
|
145
|
+
.coord-dot {
|
|
146
|
+
position: absolute; width: 20px; height: 20px; border-radius: 50%;
|
|
147
|
+
border: 2px solid #fff; transform: translate(-50%, -50%);
|
|
148
|
+
font-size: 11px; font-weight: 700; color: #fff;
|
|
149
|
+
display: flex; align-items: center; justify-content: center;
|
|
150
|
+
text-shadow: 0 0 3px rgba(0,0,0,0.8); cursor: default;
|
|
151
|
+
box-shadow: 0 0 6px rgba(0,0,0,0.5);
|
|
152
|
+
}
|
|
153
|
+
.details { flex: 1; min-width: 280px; }
|
|
154
|
+
.detail-section { margin-bottom: 12px; }
|
|
155
|
+
.action { padding: 6px 8px; margin-bottom: 4px; background: #1a1a3e; border-radius: 4px; font-size: 13px; }
|
|
156
|
+
.action-ok { border-left: 3px solid #4caf50; }
|
|
157
|
+
.action-fail { border-left: 3px solid #f44336; }
|
|
158
|
+
.action-meta { font-size: 12px; color: #888; margin-top: 2px; }
|
|
159
|
+
.fail-badge { background: #f44336; color: #fff; padding: 1px 6px; border-radius: 3px; font-size: 11px; margin-left: 8px; }
|
|
160
|
+
.comment { font-size: 13px; color: #ccc; line-height: 1.5; white-space: pre-wrap; max-height: 120px; overflow-y: auto; }
|
|
161
|
+
.detail-row { display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px; color: #888; margin-top: 8px; }
|
|
162
|
+
@media (max-width: 900px) {
|
|
163
|
+
.step-body { flex-direction: column; }
|
|
164
|
+
.screenshots { flex-direction: column; max-width: 100%; }
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
167
|
+
</head>
|
|
168
|
+
<body>
|
|
169
|
+
<div class="header">
|
|
170
|
+
<h1>Local Sim Debug Report</h1>
|
|
171
|
+
<div class="header-meta">
|
|
172
|
+
<span><strong>Tester:</strong> ${escapeHtml(meta.testerName)} (${escapeHtml(meta.testerId.slice(0, 12))})</span>
|
|
173
|
+
<span><strong>URL:</strong> ${escapeHtml(meta.url)}</span>
|
|
174
|
+
<span><strong>Format:</strong> ${escapeHtml(meta.screenFormat)}</span>
|
|
175
|
+
<span><strong>Status:</strong> <span class="badge" style="background:${meta.finalStatus === "completed" ? "#4caf50" : meta.finalStatus === "failed" ? "#f44336" : "#ff9800"}">${escapeHtml(meta.finalStatus)}</span></span>
|
|
176
|
+
<span><strong>Steps:</strong> ${totalSteps}</span>
|
|
177
|
+
<span><strong>Assignments:</strong> ${assignmentHtml}</span>
|
|
178
|
+
<span><strong>Generated:</strong> ${new Date().toISOString()}</span>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
${stepsHtml}
|
|
182
|
+
</body>
|
|
183
|
+
</html>`;
|
|
184
|
+
writeFileSync(filePath, html);
|
|
185
|
+
console.error(` Debug report: ${filePath}`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger for local simulations.
|
|
3
|
+
*
|
|
4
|
+
* When enabled (--debug flag), logs the full data flow:
|
|
5
|
+
* Backend response → normalized actions → element resolution → execution
|
|
6
|
+
*
|
|
7
|
+
* Always writes to stderr so it doesn't interfere with JSON output.
|
|
8
|
+
* Optionally also writes to a log file (~/.speqs/local-sim.log).
|
|
9
|
+
*/
|
|
10
|
+
import type { LocalSimStepResponse, LocalStepAction } from "./types.js";
|
|
11
|
+
export declare function enableDebug(opts?: {
|
|
12
|
+
file?: boolean;
|
|
13
|
+
}): void;
|
|
14
|
+
export declare function isDebugEnabled(): boolean;
|
|
15
|
+
export declare function debugObservation(obs: {
|
|
16
|
+
screenshot: string;
|
|
17
|
+
treeData: {
|
|
18
|
+
simplified: string;
|
|
19
|
+
nodeMap: Map<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
url: string;
|
|
22
|
+
viewportWidth: number;
|
|
23
|
+
viewportHeight: number;
|
|
24
|
+
scrollPosition: number;
|
|
25
|
+
}): void;
|
|
26
|
+
export declare function debugRawResponse(raw: Record<string, unknown>): void;
|
|
27
|
+
export declare function debugNormalizedActions(actions: LocalStepAction[]): void;
|
|
28
|
+
export declare function debugActionExecution(index: number, action: LocalStepAction, result: {
|
|
29
|
+
success: boolean;
|
|
30
|
+
coordinates: {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
} | null;
|
|
34
|
+
}, strategy: string): void;
|
|
35
|
+
export declare function debugForwards(forwards: Array<{
|
|
36
|
+
type: string;
|
|
37
|
+
content: string;
|
|
38
|
+
}>): void;
|
|
39
|
+
export declare function debugStepSummary(step: number, maxSteps: number, response: LocalSimStepResponse): void;
|
|
40
|
+
export declare function debugRecord(interactionCount: number, status: string, assignmentStatuses: Array<{
|
|
41
|
+
assignment_id: string;
|
|
42
|
+
status: string;
|
|
43
|
+
step_count: number;
|
|
44
|
+
}>): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger for local simulations.
|
|
3
|
+
*
|
|
4
|
+
* When enabled (--debug flag), logs the full data flow:
|
|
5
|
+
* Backend response → normalized actions → element resolution → execution
|
|
6
|
+
*
|
|
7
|
+
* Always writes to stderr so it doesn't interfere with JSON output.
|
|
8
|
+
* Optionally also writes to a log file (~/.speqs/local-sim.log).
|
|
9
|
+
*/
|
|
10
|
+
import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
const LOG_DIR = join(homedir(), ".speqs");
|
|
14
|
+
const LOG_FILE = join(LOG_DIR, "local-sim.log");
|
|
15
|
+
let debugEnabled = false;
|
|
16
|
+
let logToFile = false;
|
|
17
|
+
export function enableDebug(opts = {}) {
|
|
18
|
+
debugEnabled = true;
|
|
19
|
+
logToFile = opts.file ?? false;
|
|
20
|
+
if (logToFile) {
|
|
21
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
22
|
+
writeFileSync(LOG_FILE, `=== Local simulation debug log — ${new Date().toISOString()} ===\n`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function isDebugEnabled() {
|
|
26
|
+
return debugEnabled;
|
|
27
|
+
}
|
|
28
|
+
function write(msg) {
|
|
29
|
+
if (!debugEnabled)
|
|
30
|
+
return;
|
|
31
|
+
const line = `[${new Date().toISOString().slice(11, 23)}] ${msg}`;
|
|
32
|
+
console.error(line);
|
|
33
|
+
if (logToFile) {
|
|
34
|
+
appendFileSync(LOG_FILE, line + "\n");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// --- Logging methods ---
|
|
38
|
+
export function debugObservation(obs) {
|
|
39
|
+
write(`OBSERVE | url=${obs.url} | viewport=${obs.viewportWidth}x${obs.viewportHeight} | scroll=${obs.scrollPosition}`);
|
|
40
|
+
write(`OBSERVE | screenshot=${Math.round(obs.screenshot.length * 0.75 / 1024)}KB | tree=${obs.treeData.simplified.length} chars | nodes=${obs.treeData.nodeMap.size}`);
|
|
41
|
+
if (debugEnabled) {
|
|
42
|
+
// Log first 5 lines of the tree for quick inspection
|
|
43
|
+
const treePreview = obs.treeData.simplified.split("\n").slice(0, 5).join("\n ");
|
|
44
|
+
write(`OBSERVE | tree preview:\n ${treePreview}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function debugRawResponse(raw) {
|
|
48
|
+
const keys = Object.keys(raw);
|
|
49
|
+
write(`RESPONSE | keys: ${keys.join(", ")}`);
|
|
50
|
+
const output = raw.output;
|
|
51
|
+
if (output) {
|
|
52
|
+
write(`RESPONSE | comment: ${String(output.comment ?? "").slice(0, 100)}`);
|
|
53
|
+
write(`RESPONSE | sentiment: ${output.sentiment} | location: ${output.current_location} | completed: ${output.assignment_completed}`);
|
|
54
|
+
const action = output.action;
|
|
55
|
+
const actions = (action?.actions ?? []);
|
|
56
|
+
write(`RESPONSE | raw actions: ${actions.length} — ${actions.map(a => a.type).join(", ")}`);
|
|
57
|
+
}
|
|
58
|
+
const resolved = raw.resolved_actions;
|
|
59
|
+
if (Array.isArray(resolved)) {
|
|
60
|
+
write(`RESPONSE | resolved_actions: ${resolved.length}`);
|
|
61
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
62
|
+
const ra = resolved[i];
|
|
63
|
+
const act = ra.action;
|
|
64
|
+
write(`RESPONSE | [${i}] type=${act?.type ?? "?"} node_id=${ra.node_id ?? "null"} desc=${ra.node_description ?? "null"}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function debugNormalizedActions(actions) {
|
|
69
|
+
write(`ACTIONS | ${actions.length} normalized actions:`);
|
|
70
|
+
for (let i = 0; i < actions.length; i++) {
|
|
71
|
+
const a = actions[i];
|
|
72
|
+
write(`ACTIONS | [${i}] type=${a.type} element="${a.element_name ?? "?"}" node_id=${a.node_id ?? "null"} element_type=${a.element_type ?? "?"}`);
|
|
73
|
+
if (a.value)
|
|
74
|
+
write(`ACTIONS | [${i}] value="${a.value_type === "secret" ? "***" : a.value}" mode=${a.mode ?? "-"} submit=${a.submit ?? false}`);
|
|
75
|
+
if (a.direction)
|
|
76
|
+
write(`ACTIONS | [${i}] direction=${a.direction} amount=${a.amount ?? "-"}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function debugActionExecution(index, action, result, strategy) {
|
|
80
|
+
const status = result.success ? "OK" : "FAIL";
|
|
81
|
+
const coords = result.coordinates ? `(${result.coordinates.x}, ${result.coordinates.y})` : "no-coords";
|
|
82
|
+
write(`EXEC | [${index}] ${status} | ${action.type} on "${action.element_name ?? "?"}" | strategy=${strategy} | ${coords}`);
|
|
83
|
+
}
|
|
84
|
+
export function debugForwards(forwards) {
|
|
85
|
+
if (forwards.length === 0)
|
|
86
|
+
return;
|
|
87
|
+
write(`FORWARDS | ${forwards.length} items:`);
|
|
88
|
+
for (const f of forwards) {
|
|
89
|
+
write(`FORWARDS | ${f.type}: ${f.content.slice(0, 100)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function debugStepSummary(step, maxSteps, response) {
|
|
93
|
+
const icon = response.assignment_completed ? "DONE" : `${step + 1}/${maxSteps}`;
|
|
94
|
+
write(`STEP ${icon} | ${response.sentiment} | ${response.current_location} | effort=${response.effort_seconds}s`);
|
|
95
|
+
if (response.loop_detected)
|
|
96
|
+
write(`STEP | LOOP DETECTED`);
|
|
97
|
+
}
|
|
98
|
+
export function debugRecord(interactionCount, status, assignmentStatuses) {
|
|
99
|
+
write(`RECORD | ${interactionCount} interactions | status=${status}`);
|
|
100
|
+
for (const as of assignmentStatuses) {
|
|
101
|
+
write(`RECORD | assignment ${as.assignment_id.slice(0, 8)} — ${as.status} (${as.step_count} steps)`);
|
|
102
|
+
}
|
|
103
|
+
}
|