@ranganathmk/trq 0.1.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/README.md +61 -0
- package/bin/trq.mjs +14 -0
- package/dist/cli.mjs +1721 -0
- package/dist/studio/main/bootstrap.js +708 -0
- package/dist/studio/main/main.cjs +2525 -0
- package/dist/studio/main/replay-helpers.js +542 -0
- package/dist/studio/preload/preload.cjs +13 -0
- package/dist/studio/renderer/codicon.css +708 -0
- package/dist/studio/renderer/codicon.ttf +0 -0
- package/dist/studio/renderer/index.html +73 -0
- package/dist/studio/renderer/index.js +9555 -0
- package/package.json +46 -0
|
@@ -0,0 +1,2525 @@
|
|
|
1
|
+
var __trq_meta_url__ = require("url").pathToFileURL(__filename).href;
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
|
|
25
|
+
// src/main/main.ts
|
|
26
|
+
var import_electron2 = require("electron");
|
|
27
|
+
var import_node_path7 = __toESM(require("node:path"));
|
|
28
|
+
|
|
29
|
+
// src/main/ipc.ts
|
|
30
|
+
var import_node_fs6 = require("node:fs");
|
|
31
|
+
var import_node_path6 = __toESM(require("node:path"));
|
|
32
|
+
var import_electron = require("electron");
|
|
33
|
+
|
|
34
|
+
// ../core/dist/index.js
|
|
35
|
+
var import_module = require("module");
|
|
36
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
37
|
+
var import_node_url = require("node:url");
|
|
38
|
+
var import_node_fs = require("node:fs");
|
|
39
|
+
var import_node_fs2 = require("node:fs");
|
|
40
|
+
var import_node_fs3 = require("node:fs");
|
|
41
|
+
var import_node_path2 = __toESM(require("node:path"), 1);
|
|
42
|
+
var import_puppeteer = __toESM(require("puppeteer"), 1);
|
|
43
|
+
var import_node_path3 = __toESM(require("node:path"), 1);
|
|
44
|
+
var import_node_url2 = require("node:url");
|
|
45
|
+
var import_puppeteer2 = __toESM(require("puppeteer"), 1);
|
|
46
|
+
var import_node_fs4 = require("node:fs");
|
|
47
|
+
var import_node_path4 = __toESM(require("node:path"), 1);
|
|
48
|
+
var import_node_url3 = require("node:url");
|
|
49
|
+
var import_puppeteer3 = __toESM(require("puppeteer"), 1);
|
|
50
|
+
var import_node_fs5 = require("node:fs");
|
|
51
|
+
var import_node_path5 = __toESM(require("node:path"), 1);
|
|
52
|
+
var import_node_url4 = require("node:url");
|
|
53
|
+
var require2 = (0, import_module.createRequire)(__trq_meta_url__);
|
|
54
|
+
var __defProp2 = Object.defineProperty;
|
|
55
|
+
var __getOwnPropNames2 = Object.getOwnPropertyNames;
|
|
56
|
+
var __esm = (fn, res) => function __init() {
|
|
57
|
+
return fn && (res = (0, fn[__getOwnPropNames2(fn)[0]])(fn = 0)), res;
|
|
58
|
+
};
|
|
59
|
+
var __export = (target, all) => {
|
|
60
|
+
for (var name in all)
|
|
61
|
+
__defProp2(target, name, { get: all[name], enumerable: true });
|
|
62
|
+
};
|
|
63
|
+
var page_context_exports = {};
|
|
64
|
+
__export(page_context_exports, {
|
|
65
|
+
cdpPageContext: () => cdpPageContext,
|
|
66
|
+
puppeteerPageContext: () => puppeteerPageContext
|
|
67
|
+
});
|
|
68
|
+
function puppeteerPageContext(page, client) {
|
|
69
|
+
return {
|
|
70
|
+
client,
|
|
71
|
+
evaluate: (fnOrExpr, ...args) => page.evaluate(fnOrExpr, ...args),
|
|
72
|
+
url: () => page.url(),
|
|
73
|
+
goto: async (url, opts) => {
|
|
74
|
+
await page.goto(url, { waitUntil: "networkidle2", timeout: opts?.timeout ?? 3e4 });
|
|
75
|
+
},
|
|
76
|
+
waitForLoad: async (timeout) => {
|
|
77
|
+
await page.waitForNavigation({ timeout: timeout ?? 3e4 }).catch(() => {
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function cdpPageContext(client, options = {}) {
|
|
83
|
+
let currentUrl = options.initialUrl ?? "about:blank";
|
|
84
|
+
let mainFrameId = null;
|
|
85
|
+
void client.send("Page.getFrameTree").then((tree) => {
|
|
86
|
+
const t = tree;
|
|
87
|
+
mainFrameId = t.frameTree?.frame?.id ?? null;
|
|
88
|
+
if (t.frameTree?.frame?.url) currentUrl = t.frameTree.frame.url;
|
|
89
|
+
}).catch(() => {
|
|
90
|
+
});
|
|
91
|
+
client.on("Page.frameNavigated", (raw) => {
|
|
92
|
+
const e = raw;
|
|
93
|
+
if (e.frame.parentId) return;
|
|
94
|
+
if (mainFrameId && e.frame.id !== mainFrameId) return;
|
|
95
|
+
currentUrl = e.frame.url;
|
|
96
|
+
});
|
|
97
|
+
client.on("Page.navigatedWithinDocument", (raw) => {
|
|
98
|
+
const e = raw;
|
|
99
|
+
if (mainFrameId && e.frameId !== mainFrameId) return;
|
|
100
|
+
currentUrl = e.url;
|
|
101
|
+
});
|
|
102
|
+
const waitForLoad = (timeout = 3e4) => new Promise((resolve) => {
|
|
103
|
+
const t = setTimeout(() => {
|
|
104
|
+
client.off?.("Page.loadEventFired", onLoad);
|
|
105
|
+
resolve();
|
|
106
|
+
}, timeout);
|
|
107
|
+
const onLoad = () => {
|
|
108
|
+
clearTimeout(t);
|
|
109
|
+
client.off?.("Page.loadEventFired", onLoad);
|
|
110
|
+
resolve();
|
|
111
|
+
};
|
|
112
|
+
client.on("Page.loadEventFired", onLoad);
|
|
113
|
+
});
|
|
114
|
+
const evaluateExpr = async (expression) => {
|
|
115
|
+
const r = await client.send("Runtime.evaluate", {
|
|
116
|
+
expression,
|
|
117
|
+
awaitPromise: true,
|
|
118
|
+
returnByValue: true
|
|
119
|
+
});
|
|
120
|
+
if (r.exceptionDetails) {
|
|
121
|
+
const desc = r.exceptionDetails.exception?.description ?? r.exceptionDetails.text ?? "evaluate failed";
|
|
122
|
+
throw new Error(desc);
|
|
123
|
+
}
|
|
124
|
+
return r.result?.value;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
client,
|
|
128
|
+
evaluate: (fnOrExpr, ...args) => {
|
|
129
|
+
if (typeof fnOrExpr === "string") {
|
|
130
|
+
return evaluateExpr(fnOrExpr);
|
|
131
|
+
}
|
|
132
|
+
const body = fnOrExpr.toString();
|
|
133
|
+
const jsonArgs = args.map((a) => JSON.stringify(a)).join(",");
|
|
134
|
+
const expression = `(${body})(${jsonArgs})`;
|
|
135
|
+
return evaluateExpr(expression);
|
|
136
|
+
},
|
|
137
|
+
url: () => currentUrl,
|
|
138
|
+
goto: async (url, opts) => {
|
|
139
|
+
const loaded = waitForLoad(opts?.timeout ?? 3e4);
|
|
140
|
+
await client.send("Page.navigate", { url });
|
|
141
|
+
await loaded;
|
|
142
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
143
|
+
},
|
|
144
|
+
waitForLoad
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
var init_page_context = __esm({
|
|
148
|
+
"src/page-context.ts"() {
|
|
149
|
+
"use strict";
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
var Session = class _Session {
|
|
153
|
+
constructor(dir, manifest) {
|
|
154
|
+
this.dir = dir;
|
|
155
|
+
this.manifest = manifest;
|
|
156
|
+
}
|
|
157
|
+
writeChain = Promise.resolve();
|
|
158
|
+
static async create(rootDir, name, initialUrl) {
|
|
159
|
+
const dir = import_node_path2.default.join(rootDir, name);
|
|
160
|
+
if ((0, import_node_fs3.existsSync)(dir)) {
|
|
161
|
+
throw new Error(`Session already exists: ${name}`);
|
|
162
|
+
}
|
|
163
|
+
await import_node_fs2.promises.mkdir(dir, { recursive: true });
|
|
164
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
165
|
+
const manifest = {
|
|
166
|
+
id: `ses_${randomId()}`,
|
|
167
|
+
name,
|
|
168
|
+
createdAt: now,
|
|
169
|
+
updatedAt: now,
|
|
170
|
+
stepCount: 0,
|
|
171
|
+
initialUrl: initialUrl ?? null,
|
|
172
|
+
lastUrl: initialUrl ?? null
|
|
173
|
+
};
|
|
174
|
+
await import_node_fs2.promises.writeFile(import_node_path2.default.join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
175
|
+
await import_node_fs2.promises.writeFile(import_node_path2.default.join(dir, "events.jsonl"), "");
|
|
176
|
+
return new _Session(dir, manifest);
|
|
177
|
+
}
|
|
178
|
+
static async load(rootDir, name) {
|
|
179
|
+
const dir = import_node_path2.default.join(rootDir, name);
|
|
180
|
+
const raw = await import_node_fs2.promises.readFile(import_node_path2.default.join(dir, "manifest.json"), "utf8");
|
|
181
|
+
return new _Session(dir, JSON.parse(raw));
|
|
182
|
+
}
|
|
183
|
+
static async list(rootDir) {
|
|
184
|
+
if (!(0, import_node_fs3.existsSync)(rootDir)) return [];
|
|
185
|
+
const names = await import_node_fs2.promises.readdir(rootDir);
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const name of names) {
|
|
188
|
+
const mp = import_node_path2.default.join(rootDir, name, "manifest.json");
|
|
189
|
+
if (!(0, import_node_fs3.existsSync)(mp)) continue;
|
|
190
|
+
try {
|
|
191
|
+
out.push(JSON.parse(await import_node_fs2.promises.readFile(mp, "utf8")));
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
196
|
+
}
|
|
197
|
+
// Builds an event ID from its 1-based position. Used by all mutations so
|
|
198
|
+
// IDs stay sequential — after a delete or insert, every event keeps a
|
|
199
|
+
// position-matching id (evt_0001..evt_000N with no gaps).
|
|
200
|
+
idFor(pos1Based) {
|
|
201
|
+
return `evt_${String(pos1Based).padStart(4, "0")}`;
|
|
202
|
+
}
|
|
203
|
+
appendEvent(event) {
|
|
204
|
+
const next = this.writeChain.then(async () => {
|
|
205
|
+
this.manifest.stepCount++;
|
|
206
|
+
const id = this.idFor(this.manifest.stepCount);
|
|
207
|
+
const stamped = { ...event, id };
|
|
208
|
+
await import_node_fs2.promises.appendFile(import_node_path2.default.join(this.dir, "events.jsonl"), JSON.stringify(stamped) + "\n");
|
|
209
|
+
this.manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
210
|
+
if (this.manifest.initialUrl === null) this.manifest.initialUrl = event.url;
|
|
211
|
+
this.manifest.lastUrl = event.url;
|
|
212
|
+
await this.flushManifest();
|
|
213
|
+
return stamped;
|
|
214
|
+
});
|
|
215
|
+
this.writeChain = next.catch(() => {
|
|
216
|
+
});
|
|
217
|
+
return next;
|
|
218
|
+
}
|
|
219
|
+
// Replace fields on an existing event (matched by id). Selectors, value,
|
|
220
|
+
// assertion spec, etc. The id itself and the event's index are preserved.
|
|
221
|
+
// Use this for fixing brittle selectors, correcting an input value, etc.
|
|
222
|
+
async replaceEvent(id, patch) {
|
|
223
|
+
const next = this.writeChain.then(async () => {
|
|
224
|
+
const events = await this.readEvents();
|
|
225
|
+
const idx = events.findIndex((e) => e.id === id);
|
|
226
|
+
if (idx === -1) throw new Error(`No event with id ${id}`);
|
|
227
|
+
const merged = { ...events[idx], ...patch, id };
|
|
228
|
+
events[idx] = merged;
|
|
229
|
+
await this.writeAllEvents(events);
|
|
230
|
+
this.manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
231
|
+
await this.flushManifest();
|
|
232
|
+
return merged;
|
|
233
|
+
});
|
|
234
|
+
this.writeChain = next.catch(() => {
|
|
235
|
+
});
|
|
236
|
+
return next;
|
|
237
|
+
}
|
|
238
|
+
// Delete an event by id. Subsequent events are renumbered so IDs stay
|
|
239
|
+
// sequential (evt_0001..evt_000N with no gaps). stepCount = new length.
|
|
240
|
+
async removeEvent(id) {
|
|
241
|
+
const next = this.writeChain.then(async () => {
|
|
242
|
+
const events = await this.readEvents();
|
|
243
|
+
const idx = events.findIndex((e) => e.id === id);
|
|
244
|
+
if (idx === -1) throw new Error(`No event with id ${id}`);
|
|
245
|
+
events.splice(idx, 1);
|
|
246
|
+
this.renumber(events);
|
|
247
|
+
this.manifest.stepCount = events.length;
|
|
248
|
+
this.manifest.lastUrl = events[events.length - 1]?.url ?? this.manifest.lastUrl;
|
|
249
|
+
this.manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
250
|
+
await this.writeAllEvents(events);
|
|
251
|
+
await this.flushManifest();
|
|
252
|
+
});
|
|
253
|
+
this.writeChain = next.catch(() => {
|
|
254
|
+
});
|
|
255
|
+
return next;
|
|
256
|
+
}
|
|
257
|
+
// Insert a new event at the given 0-based index (so 0 means "before first",
|
|
258
|
+
// events.length means "at end"). The whole event list is renumbered to
|
|
259
|
+
// stay sequential. Returns the stamped event with its post-renumber id.
|
|
260
|
+
async insertEventAt(idx, event) {
|
|
261
|
+
const next = this.writeChain.then(async () => {
|
|
262
|
+
const events = await this.readEvents();
|
|
263
|
+
const safeIdx = Math.max(0, Math.min(idx, events.length));
|
|
264
|
+
const stamped = { ...event, id: "evt_xxxx" };
|
|
265
|
+
events.splice(safeIdx, 0, stamped);
|
|
266
|
+
this.renumber(events);
|
|
267
|
+
this.manifest.stepCount = events.length;
|
|
268
|
+
if (this.manifest.initialUrl === null) this.manifest.initialUrl = event.url;
|
|
269
|
+
this.manifest.lastUrl = events[events.length - 1].url;
|
|
270
|
+
this.manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
271
|
+
await this.writeAllEvents(events);
|
|
272
|
+
await this.flushManifest();
|
|
273
|
+
return events[safeIdx];
|
|
274
|
+
});
|
|
275
|
+
this.writeChain = next.catch(() => {
|
|
276
|
+
});
|
|
277
|
+
return next;
|
|
278
|
+
}
|
|
279
|
+
renumber(events) {
|
|
280
|
+
for (let i = 0; i < events.length; i++) {
|
|
281
|
+
events[i].id = this.idFor(i + 1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Create a new session containing events 1..throughStep (inclusive,
|
|
285
|
+
// 1-based) from this session. Copies config.json. Useful for forking a
|
|
286
|
+
// recording at a known-good step to retry a divergent path.
|
|
287
|
+
async branch(rootDir, newName, throughStep) {
|
|
288
|
+
const events = await this.readEvents();
|
|
289
|
+
const target = Math.max(0, Math.min(throughStep, events.length));
|
|
290
|
+
const sliced = events.slice(0, target);
|
|
291
|
+
const branched = await _Session.create(rootDir, newName, this.manifest.initialUrl);
|
|
292
|
+
const lines = [];
|
|
293
|
+
for (let i = 0; i < sliced.length; i++) {
|
|
294
|
+
const id = `evt_${String(i + 1).padStart(4, "0")}`;
|
|
295
|
+
const e = { ...sliced[i], id };
|
|
296
|
+
lines.push(JSON.stringify(e));
|
|
297
|
+
}
|
|
298
|
+
await import_node_fs2.promises.writeFile(import_node_path2.default.join(branched.dir, "events.jsonl"), lines.length ? lines.join("\n") + "\n" : "");
|
|
299
|
+
branched.manifest.stepCount = sliced.length;
|
|
300
|
+
branched.manifest.lastUrl = sliced[sliced.length - 1]?.url ?? branched.manifest.initialUrl;
|
|
301
|
+
branched.manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
302
|
+
await branched.flushManifest();
|
|
303
|
+
const cfg = await this.readConfig();
|
|
304
|
+
if (Object.keys(cfg).length > 0) await branched.writeConfig(cfg);
|
|
305
|
+
return branched;
|
|
306
|
+
}
|
|
307
|
+
async writeAllEvents(events) {
|
|
308
|
+
const body = events.length ? events.map((e) => JSON.stringify(e)).join("\n") + "\n" : "";
|
|
309
|
+
await import_node_fs2.promises.writeFile(import_node_path2.default.join(this.dir, "events.jsonl"), body);
|
|
310
|
+
}
|
|
311
|
+
async flushManifest() {
|
|
312
|
+
await import_node_fs2.promises.writeFile(
|
|
313
|
+
import_node_path2.default.join(this.dir, "manifest.json"),
|
|
314
|
+
JSON.stringify(this.manifest, null, 2)
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
async readEvents() {
|
|
318
|
+
const raw = await import_node_fs2.promises.readFile(import_node_path2.default.join(this.dir, "events.jsonl"), "utf8");
|
|
319
|
+
return raw.split("\n").map((l) => l.trim()).filter(Boolean).map((l) => JSON.parse(l));
|
|
320
|
+
}
|
|
321
|
+
async readConfig() {
|
|
322
|
+
const p = import_node_path2.default.join(this.dir, "config.json");
|
|
323
|
+
if (!(0, import_node_fs3.existsSync)(p)) return {};
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(await import_node_fs2.promises.readFile(p, "utf8"));
|
|
326
|
+
} catch {
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
async writeConfig(cfg) {
|
|
331
|
+
await import_node_fs2.promises.writeFile(
|
|
332
|
+
import_node_path2.default.join(this.dir, "config.json"),
|
|
333
|
+
JSON.stringify(cfg, null, 2)
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
function randomId() {
|
|
338
|
+
return Math.random().toString(36).slice(2, 8) + Date.now().toString(36).slice(-4);
|
|
339
|
+
}
|
|
340
|
+
async function safeSend(client, method, params) {
|
|
341
|
+
try {
|
|
342
|
+
await client.send(method, params);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.warn(`[config] ${method} failed: ${err.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function applyConfig(client, cfg, options = {}) {
|
|
348
|
+
if (cfg.viewport) {
|
|
349
|
+
await safeSend(client, "Emulation.setDeviceMetricsOverride", {
|
|
350
|
+
width: cfg.viewport.width,
|
|
351
|
+
height: cfg.viewport.height,
|
|
352
|
+
deviceScaleFactor: cfg.viewport.deviceScaleFactor ?? 1,
|
|
353
|
+
mobile: cfg.viewport.mobile ?? false,
|
|
354
|
+
// Critical: without this, Chromium expands the visible BrowserView area
|
|
355
|
+
// to match the device-metrics size, overlapping any UI below it. The
|
|
356
|
+
// visible bounds are owned by view.setBounds elsewhere; this command
|
|
357
|
+
// should only affect what the page reports as its viewport.
|
|
358
|
+
dontSetVisibleSize: true
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (cfg.locale) {
|
|
362
|
+
await safeSend(client, "Emulation.setLocaleOverride", { locale: cfg.locale });
|
|
363
|
+
}
|
|
364
|
+
if (cfg.timezone) {
|
|
365
|
+
await safeSend(client, "Emulation.setTimezoneOverride", {
|
|
366
|
+
timezoneId: cfg.timezone
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if (cfg.userAgent) {
|
|
370
|
+
await safeSend(client, "Network.setUserAgentOverride", { userAgent: cfg.userAgent });
|
|
371
|
+
}
|
|
372
|
+
if (cfg.extraHeaders && Object.keys(cfg.extraHeaders).length > 0) {
|
|
373
|
+
await safeSend(client, "Network.enable", {});
|
|
374
|
+
await safeSend(client, "Network.setExtraHTTPHeaders", { headers: cfg.extraHeaders });
|
|
375
|
+
}
|
|
376
|
+
if (cfg.bypassCSP) {
|
|
377
|
+
await safeSend(client, "Page.setBypassCSP", { enabled: true });
|
|
378
|
+
}
|
|
379
|
+
if (cfg.permissions && cfg.permissions.length > 0) {
|
|
380
|
+
const params = {
|
|
381
|
+
permissions: cfg.permissions
|
|
382
|
+
};
|
|
383
|
+
if (options.origin) params.origin = options.origin;
|
|
384
|
+
await safeSend(client, "Browser.grantPermissions", params);
|
|
385
|
+
}
|
|
386
|
+
if (cfg.geolocation) {
|
|
387
|
+
await safeSend(client, "Emulation.setGeolocationOverride", {
|
|
388
|
+
latitude: cfg.geolocation.latitude,
|
|
389
|
+
longitude: cfg.geolocation.longitude,
|
|
390
|
+
accuracy: cfg.geolocation.accuracy ?? 10
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
var BINDING_NAME = "__trqEmit";
|
|
395
|
+
var isRecordableUrl = (url) => !!url && url !== "about:blank" && !url.startsWith("chrome://") && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://") && !url.startsWith("data:");
|
|
396
|
+
async function broadcastStepCount(ctx) {
|
|
397
|
+
const n = ctx.session.manifest.stepCount;
|
|
398
|
+
const expr = `window.__trqSetStep && window.__trqSetStep(${n})`;
|
|
399
|
+
await Promise.all(
|
|
400
|
+
Array.from(ctx.clients).map(
|
|
401
|
+
(c) => c.send("Runtime.evaluate", { expression: expr }).catch(() => {
|
|
402
|
+
})
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
async function recordNav(ctx, url, source) {
|
|
407
|
+
if (!isRecordableUrl(url)) return;
|
|
408
|
+
if (source === "load" && isAnchorClickPrev(ctx.lastEmittedEvent)) return;
|
|
409
|
+
const stamped = await ctx.session.appendEvent({
|
|
410
|
+
type: "navigate",
|
|
411
|
+
timestamp: Date.now(),
|
|
412
|
+
url,
|
|
413
|
+
frame: "main",
|
|
414
|
+
source
|
|
415
|
+
});
|
|
416
|
+
ctx.lastEmittedEvent = stamped;
|
|
417
|
+
ctx.onEvent?.(stamped);
|
|
418
|
+
await broadcastStepCount(ctx);
|
|
419
|
+
}
|
|
420
|
+
function isAnchorClickPrev(prev) {
|
|
421
|
+
if (!prev || prev.type !== "click") return false;
|
|
422
|
+
const t = prev.target;
|
|
423
|
+
return t.tagName === "A" || t.attributes?.target === "_blank" || !!t.attributes?.href;
|
|
424
|
+
}
|
|
425
|
+
async function setupRecordingOnClient(client, ctx) {
|
|
426
|
+
ctx.clients.add(client);
|
|
427
|
+
client.on("disconnected", () => ctx.clients.delete(client));
|
|
428
|
+
await client.send("Runtime.enable");
|
|
429
|
+
await client.send("Page.enable");
|
|
430
|
+
if (ctx.config) {
|
|
431
|
+
await applyConfig(client, ctx.config);
|
|
432
|
+
}
|
|
433
|
+
await client.send("Runtime.addBinding", { name: BINDING_NAME }).catch(() => {
|
|
434
|
+
});
|
|
435
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: ctx.bootstrap });
|
|
436
|
+
let mainFrameId = null;
|
|
437
|
+
try {
|
|
438
|
+
const tree = await client.send("Page.getFrameTree");
|
|
439
|
+
mainFrameId = tree.frameTree.frame.id;
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
let lastNavUrl = null;
|
|
443
|
+
client.on("Runtime.bindingCalled", async (raw) => {
|
|
444
|
+
const event = raw;
|
|
445
|
+
if (event.name !== BINDING_NAME) return;
|
|
446
|
+
try {
|
|
447
|
+
const incoming = JSON.parse(event.payload);
|
|
448
|
+
if (incoming.__trq === "ready") {
|
|
449
|
+
await broadcastStepCount(ctx);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const stamped = await ctx.session.appendEvent(
|
|
453
|
+
incoming
|
|
454
|
+
);
|
|
455
|
+
ctx.lastEmittedEvent = stamped;
|
|
456
|
+
ctx.onEvent?.(stamped);
|
|
457
|
+
await broadcastStepCount(ctx);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
console.error("\nFailed to handle event:", err.message);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
client.on("Page.frameNavigated", async (raw) => {
|
|
463
|
+
const e = raw;
|
|
464
|
+
if (e.frame.parentId) return;
|
|
465
|
+
const url = e.frame.url;
|
|
466
|
+
if (!isRecordableUrl(url)) return;
|
|
467
|
+
if (url === lastNavUrl) return;
|
|
468
|
+
lastNavUrl = url;
|
|
469
|
+
await recordNav(ctx, url, "load");
|
|
470
|
+
});
|
|
471
|
+
client.on("Page.navigatedWithinDocument", async (raw) => {
|
|
472
|
+
const e = raw;
|
|
473
|
+
if (mainFrameId && e.frameId !== mainFrameId) return;
|
|
474
|
+
if (!isRecordableUrl(e.url)) return;
|
|
475
|
+
if (e.url === lastNavUrl) return;
|
|
476
|
+
lastNavUrl = e.url;
|
|
477
|
+
await recordNav(ctx, e.url, "in-document");
|
|
478
|
+
});
|
|
479
|
+
await client.send("Runtime.evaluate", { expression: ctx.bootstrap }).catch(() => {
|
|
480
|
+
});
|
|
481
|
+
await broadcastStepCount(ctx);
|
|
482
|
+
}
|
|
483
|
+
var __dirname2 = import_node_path3.default.dirname((0, import_node_url2.fileURLToPath)(__trq_meta_url__));
|
|
484
|
+
var BOOTSTRAP_PATH = import_node_path3.default.join(__dirname2, "bootstrap.js");
|
|
485
|
+
init_page_context();
|
|
486
|
+
var __dirname22 = import_node_path4.default.dirname((0, import_node_url3.fileURLToPath)(__trq_meta_url__));
|
|
487
|
+
var HELPERS_PATH = import_node_path4.default.join(__dirname22, "replay-helpers.js");
|
|
488
|
+
var ANIMATION_KILL_CSS = `
|
|
489
|
+
*, *::before, *::after {
|
|
490
|
+
animation-duration: 0s !important;
|
|
491
|
+
animation-delay: 0s !important;
|
|
492
|
+
transition-duration: 0s !important;
|
|
493
|
+
transition-delay: 0s !important;
|
|
494
|
+
scroll-behavior: auto !important;
|
|
495
|
+
}
|
|
496
|
+
`;
|
|
497
|
+
var INJECT_ANIMATION_KILL = `
|
|
498
|
+
(function () {
|
|
499
|
+
try {
|
|
500
|
+
const s = document.createElement('style');
|
|
501
|
+
s.setAttribute('data-trq', 'animkill');
|
|
502
|
+
s.textContent = ${JSON.stringify(ANIMATION_KILL_CSS)};
|
|
503
|
+
(document.head || document.documentElement).appendChild(s);
|
|
504
|
+
} catch (e) {}
|
|
505
|
+
})();
|
|
506
|
+
`;
|
|
507
|
+
function modBits(mods) {
|
|
508
|
+
let b = 0;
|
|
509
|
+
for (const m of mods) {
|
|
510
|
+
if (m === "Alt") b |= 1;
|
|
511
|
+
if (m === "Control") b |= 2;
|
|
512
|
+
if (m === "Meta") b |= 4;
|
|
513
|
+
if (m === "Shift") b |= 8;
|
|
514
|
+
}
|
|
515
|
+
return b;
|
|
516
|
+
}
|
|
517
|
+
var MOUSE_BUTTON = {
|
|
518
|
+
left: "left",
|
|
519
|
+
right: "right",
|
|
520
|
+
middle: "middle"
|
|
521
|
+
};
|
|
522
|
+
async function waitFor(predicate, timeout = 5e3, pollMs = 100) {
|
|
523
|
+
const start = Date.now();
|
|
524
|
+
while (Date.now() - start < timeout) {
|
|
525
|
+
if (await predicate()) return true;
|
|
526
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
async function waitForResolvable(ctx, selectors, timeout = 5e3) {
|
|
531
|
+
const ok = await waitFor(
|
|
532
|
+
async () => await ctx.evaluate((sels) => {
|
|
533
|
+
const w = window;
|
|
534
|
+
return !!w.__trqResolve?.(sels)?.found;
|
|
535
|
+
}, selectors).catch(() => false),
|
|
536
|
+
timeout
|
|
537
|
+
);
|
|
538
|
+
if (!ok) {
|
|
539
|
+
throw new Error(`selector did not resolve within ${timeout}ms: ${selectors[0]?.[0] ?? "?"}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async function replayClick(e, ctx) {
|
|
543
|
+
await waitForResolvable(ctx, e.selectors);
|
|
544
|
+
const result = await ctx.evaluate(
|
|
545
|
+
(sels) => window.__trqClick(sels),
|
|
546
|
+
e.selectors
|
|
547
|
+
);
|
|
548
|
+
if (result.error || !result.rect) throw new Error(result.error || "no rect from click resolve");
|
|
549
|
+
const { rect } = result;
|
|
550
|
+
const x = rect.x + Math.min(e.click.offsetX, rect.w);
|
|
551
|
+
const y = rect.y + Math.min(e.click.offsetY, rect.h);
|
|
552
|
+
const button = MOUSE_BUTTON[e.click.button] ?? "left";
|
|
553
|
+
const modifiers = modBits(e.click.modifiers);
|
|
554
|
+
await ctx.client.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
|
|
555
|
+
await ctx.client.send("Input.dispatchMouseEvent", {
|
|
556
|
+
type: "mousePressed",
|
|
557
|
+
x,
|
|
558
|
+
y,
|
|
559
|
+
button,
|
|
560
|
+
modifiers,
|
|
561
|
+
clickCount: e.click.clickCount
|
|
562
|
+
});
|
|
563
|
+
await ctx.client.send("Input.dispatchMouseEvent", {
|
|
564
|
+
type: "mouseReleased",
|
|
565
|
+
x,
|
|
566
|
+
y,
|
|
567
|
+
button,
|
|
568
|
+
modifiers,
|
|
569
|
+
clickCount: e.click.clickCount
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async function replayInput(e, ctx) {
|
|
573
|
+
await waitForResolvable(ctx, e.selectors);
|
|
574
|
+
const result = await ctx.evaluate(
|
|
575
|
+
(sels, val) => window.__trqSetInput(sels, val),
|
|
576
|
+
e.selectors,
|
|
577
|
+
e.value
|
|
578
|
+
);
|
|
579
|
+
if (result.error) throw new Error(result.error);
|
|
580
|
+
}
|
|
581
|
+
async function replayKey(e, ctx) {
|
|
582
|
+
await ctx.evaluate(
|
|
583
|
+
(sels) => window.__trqFocus?.(sels),
|
|
584
|
+
e.selectors
|
|
585
|
+
).catch(() => {
|
|
586
|
+
});
|
|
587
|
+
const mods = modBits(e.modifiers);
|
|
588
|
+
await ctx.client.send("Input.dispatchKeyEvent", {
|
|
589
|
+
type: "keyDown",
|
|
590
|
+
key: e.key,
|
|
591
|
+
code: e.key,
|
|
592
|
+
modifiers: mods
|
|
593
|
+
});
|
|
594
|
+
await ctx.client.send("Input.dispatchKeyEvent", {
|
|
595
|
+
type: "keyUp",
|
|
596
|
+
key: e.key,
|
|
597
|
+
code: e.key,
|
|
598
|
+
modifiers: mods
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
async function replayAssert(e, ctx) {
|
|
602
|
+
await waitForResolvable(ctx, e.selectors);
|
|
603
|
+
const result = await ctx.evaluate(
|
|
604
|
+
(sels, spec) => window.__trqAssert(sels, spec),
|
|
605
|
+
e.selectors,
|
|
606
|
+
e.assertion
|
|
607
|
+
);
|
|
608
|
+
if (!result.ok) throw new Error(result.reason ?? "assertion failed");
|
|
609
|
+
}
|
|
610
|
+
async function replayNavigate(e, ctx, prev) {
|
|
611
|
+
const wasUserAction = prev && (prev.type === "click" || prev.type === "key");
|
|
612
|
+
const prevClickTarget = prev?.type === "click" ? prev.target : null;
|
|
613
|
+
const prevIsAnchorClick = !!prevClickTarget && (prevClickTarget.tagName === "A" || prevClickTarget.attributes?.target === "_blank" || !!prevClickTarget.attributes?.href);
|
|
614
|
+
if (e.source === "in-document") {
|
|
615
|
+
const ok = await waitFor(async () => ctx.url() === e.url, 5e3);
|
|
616
|
+
if (!ok) {
|
|
617
|
+
throw new Error(`in-document nav to ${e.url} did not occur (still at ${ctx.url()})`);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (prevIsAnchorClick) {
|
|
622
|
+
const before = ctx.url();
|
|
623
|
+
const ok = await waitFor(async () => ctx.url() !== before, 1e4);
|
|
624
|
+
if (!ok) {
|
|
625
|
+
throw new Error(`anchor click did not trigger a navigation (still at ${before})`);
|
|
626
|
+
}
|
|
627
|
+
await waitFor(
|
|
628
|
+
async () => await ctx.evaluate(() => document.readyState) === "complete",
|
|
629
|
+
5e3
|
|
630
|
+
).catch(() => false);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (wasUserAction) {
|
|
634
|
+
const ok = await waitFor(async () => ctx.url() === e.url, 5e3);
|
|
635
|
+
if (ok) return;
|
|
636
|
+
}
|
|
637
|
+
await ctx.goto(e.url, { timeout: 3e4 });
|
|
638
|
+
}
|
|
639
|
+
function isAnchorClick(e) {
|
|
640
|
+
if (e.type !== "click") return false;
|
|
641
|
+
const t = e.target;
|
|
642
|
+
return t.tagName === "A" || t.attributes?.target === "_blank" || !!t.attributes?.href;
|
|
643
|
+
}
|
|
644
|
+
function urlAreaOf(url) {
|
|
645
|
+
if (!url) return null;
|
|
646
|
+
try {
|
|
647
|
+
const u = new URL(url);
|
|
648
|
+
const segs = u.pathname.split("/").filter((s) => s.length > 0);
|
|
649
|
+
return { origin: u.origin, head: segs[0] ?? "" };
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
function urlInArea(url, area) {
|
|
655
|
+
try {
|
|
656
|
+
const u = new URL(url);
|
|
657
|
+
const head = u.pathname.split("/").filter((s) => s.length > 0)[0] ?? "";
|
|
658
|
+
return u.origin === area.origin && head === area.head;
|
|
659
|
+
} catch {
|
|
660
|
+
return false;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
async function waitForAnchorClickConsequence(getActiveClient, fallbackClient, ctxFor, clientBefore, expectedNextUrl, nextEventSelectors, switchTimeoutMs = 3e3, totalTimeoutMs = 2e4, stableWindowMs = 1200) {
|
|
664
|
+
const start = Date.now();
|
|
665
|
+
while (Date.now() - start < switchTimeoutMs) {
|
|
666
|
+
const cNow = getActiveClient?.() ?? fallbackClient;
|
|
667
|
+
if (cNow !== clientBefore) break;
|
|
668
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
669
|
+
}
|
|
670
|
+
const cFinal = getActiveClient?.() ?? fallbackClient;
|
|
671
|
+
const ctxFinal = ctxFor(cFinal);
|
|
672
|
+
const expectedArea = urlAreaOf(expectedNextUrl);
|
|
673
|
+
let lastUrl = null;
|
|
674
|
+
let stableSince = Date.now();
|
|
675
|
+
while (Date.now() - start < totalTimeoutMs) {
|
|
676
|
+
let ready = false;
|
|
677
|
+
try {
|
|
678
|
+
const v = await ctxFinal.evaluate(() => document.readyState);
|
|
679
|
+
ready = v === "complete";
|
|
680
|
+
} catch {
|
|
681
|
+
}
|
|
682
|
+
const u = ctxFinal.url();
|
|
683
|
+
if (u !== lastUrl) {
|
|
684
|
+
lastUrl = u;
|
|
685
|
+
stableSince = Date.now();
|
|
686
|
+
}
|
|
687
|
+
const inArea = expectedArea ? urlInArea(u, expectedArea) : true;
|
|
688
|
+
if (ready && inArea && Date.now() - stableSince >= stableWindowMs) break;
|
|
689
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
690
|
+
}
|
|
691
|
+
if (nextEventSelectors && nextEventSelectors.length > 0) {
|
|
692
|
+
const remaining = Math.max(0, totalTimeoutMs - (Date.now() - start));
|
|
693
|
+
await waitFor(
|
|
694
|
+
async () => {
|
|
695
|
+
try {
|
|
696
|
+
const found = await ctxFinal.evaluate((sels) => {
|
|
697
|
+
const w = window;
|
|
698
|
+
return !!w.__trqResolve?.(sels)?.found;
|
|
699
|
+
}, nextEventSelectors);
|
|
700
|
+
return found;
|
|
701
|
+
} catch {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
remaining
|
|
706
|
+
).catch(() => false);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
async function setupReplayClient(client, opts = {}) {
|
|
710
|
+
const helpers = await import_node_fs4.promises.readFile(HELPERS_PATH, "utf8");
|
|
711
|
+
await client.send("Page.enable");
|
|
712
|
+
await client.send("Runtime.enable");
|
|
713
|
+
if (opts.config) {
|
|
714
|
+
await applyConfig(client, opts.config);
|
|
715
|
+
}
|
|
716
|
+
if (process.env.TRQ_DEBUG_REPLAY) {
|
|
717
|
+
const setFlag = "window.__trqDebugResolve = true;";
|
|
718
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: setFlag });
|
|
719
|
+
await client.send("Runtime.evaluate", { expression: setFlag }).catch(() => {
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: helpers });
|
|
723
|
+
if (opts.killAnimations !== false) {
|
|
724
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
725
|
+
source: INJECT_ANIMATION_KILL
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
await client.send("Runtime.evaluate", { expression: helpers }).catch(() => {
|
|
729
|
+
});
|
|
730
|
+
if (opts.killAnimations !== false) {
|
|
731
|
+
await client.send("Runtime.evaluate", { expression: INJECT_ANIMATION_KILL }).catch(() => {
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function runReplayOnClient(opts) {
|
|
736
|
+
let events = await opts.session.readEvents();
|
|
737
|
+
let total = events.length;
|
|
738
|
+
let target = opts.toStep != null ? Math.min(opts.toStep, total) : total;
|
|
739
|
+
const config = await opts.session.readConfig();
|
|
740
|
+
await setupReplayClient(opts.client, {
|
|
741
|
+
config,
|
|
742
|
+
killAnimations: opts.killAnimations
|
|
743
|
+
});
|
|
744
|
+
const { cdpPageContext: cdpPageContext2 } = await Promise.resolve().then(() => (init_page_context(), page_context_exports));
|
|
745
|
+
const ctxCache = /* @__PURE__ */ new Map();
|
|
746
|
+
const ctxFor = (c) => {
|
|
747
|
+
let cached = ctxCache.get(c);
|
|
748
|
+
if (!cached) {
|
|
749
|
+
cached = cdpPageContext2(c);
|
|
750
|
+
ctxCache.set(c, cached);
|
|
751
|
+
}
|
|
752
|
+
return cached;
|
|
753
|
+
};
|
|
754
|
+
const activeCtx = () => ctxFor(opts.getActiveClient?.() ?? opts.client);
|
|
755
|
+
if (opts.startUrl && opts.startUrl !== "about:blank") {
|
|
756
|
+
await activeCtx().goto(opts.startUrl).catch(() => {
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
let completed = 0;
|
|
760
|
+
let prev = null;
|
|
761
|
+
for (let i = 0; i < target; i++) {
|
|
762
|
+
if (opts.shouldCancel?.()) {
|
|
763
|
+
return { completed, total };
|
|
764
|
+
}
|
|
765
|
+
const e = events[i];
|
|
766
|
+
await opts.shouldWait?.(i, target, e);
|
|
767
|
+
if (opts.shouldCancel?.()) {
|
|
768
|
+
return { completed, total };
|
|
769
|
+
}
|
|
770
|
+
opts.onProgress?.(i + 1, target, e, "start");
|
|
771
|
+
const ctx = activeCtx();
|
|
772
|
+
if (process.env.TRQ_DEBUG_REPLAY) {
|
|
773
|
+
console.log(`[replay] step ${i + 1}/${target} ${e.type} @ ${ctx.url()}`);
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
const clientBefore = opts.getActiveClient?.() ?? opts.client;
|
|
777
|
+
switch (e.type) {
|
|
778
|
+
case "navigate":
|
|
779
|
+
await replayNavigate(e, ctx, prev);
|
|
780
|
+
break;
|
|
781
|
+
case "click":
|
|
782
|
+
await replayClick(e, ctx);
|
|
783
|
+
break;
|
|
784
|
+
case "input":
|
|
785
|
+
await replayInput(e, ctx);
|
|
786
|
+
break;
|
|
787
|
+
case "key":
|
|
788
|
+
await replayKey(e, ctx);
|
|
789
|
+
break;
|
|
790
|
+
case "assert":
|
|
791
|
+
await replayAssert(e, ctx);
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
if (e.type === "click" && isAnchorClick(e)) {
|
|
795
|
+
const next = events[i + 1];
|
|
796
|
+
const expectedNextUrl = next && "url" in next ? next.url : void 0;
|
|
797
|
+
const nextSelectors = next && "selectors" in next ? next.selectors : void 0;
|
|
798
|
+
await waitForAnchorClickConsequence(
|
|
799
|
+
opts.getActiveClient,
|
|
800
|
+
opts.client,
|
|
801
|
+
ctxFor,
|
|
802
|
+
clientBefore,
|
|
803
|
+
expectedNextUrl,
|
|
804
|
+
nextSelectors
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
const settle = opts.slow ? 400 : e.type === "click" || e.type === "key" ? 60 : 20;
|
|
808
|
+
if (settle) await new Promise((r) => setTimeout(r, settle));
|
|
809
|
+
completed++;
|
|
810
|
+
prev = e;
|
|
811
|
+
opts.onProgress?.(i + 1, target, e, "ok");
|
|
812
|
+
} catch (err) {
|
|
813
|
+
const msg = err.message;
|
|
814
|
+
opts.onProgress?.(i + 1, target, e, "fail", msg);
|
|
815
|
+
if (opts.shouldPauseOnFail) {
|
|
816
|
+
const action = await opts.shouldPauseOnFail(i, target, e, msg);
|
|
817
|
+
if (action === "retry" || action === "skip") {
|
|
818
|
+
events = await opts.session.readEvents();
|
|
819
|
+
total = events.length;
|
|
820
|
+
target = opts.toStep != null ? Math.min(opts.toStep, total) : total;
|
|
821
|
+
if (action === "retry") {
|
|
822
|
+
const newIdx2 = events.findIndex((ev) => ev.id === e.id);
|
|
823
|
+
i = (newIdx2 === -1 ? i : newIdx2) - 1;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
const newIdx = events.findIndex((ev) => ev.id === e.id);
|
|
827
|
+
if (newIdx !== -1) i = newIdx;
|
|
828
|
+
prev = events[i] ?? e;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return {
|
|
833
|
+
completed,
|
|
834
|
+
total,
|
|
835
|
+
failed: { step: i + 1, reason: msg, event: e }
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return { completed, total };
|
|
840
|
+
}
|
|
841
|
+
init_page_context();
|
|
842
|
+
var __dirname3 = import_node_path5.default.dirname((0, import_node_url4.fileURLToPath)(__trq_meta_url__));
|
|
843
|
+
var BOOTSTRAP_PATH2 = import_node_path5.default.join(__dirname3, "bootstrap.js");
|
|
844
|
+
var BINDING_NAME2 = "__trqEmit";
|
|
845
|
+
async function runResumeOnClient(opts) {
|
|
846
|
+
let events = await opts.session.readEvents();
|
|
847
|
+
let total = events.length;
|
|
848
|
+
const helpers = await import_node_fs5.promises.readFile(HELPERS_PATH, "utf8");
|
|
849
|
+
const bootstrap = await import_node_fs5.promises.readFile(BOOTSTRAP_PATH2, "utf8");
|
|
850
|
+
const config = await opts.session.readConfig();
|
|
851
|
+
const client = opts.client;
|
|
852
|
+
let mode = "replaying";
|
|
853
|
+
const broadcastStepCount2 = async () => {
|
|
854
|
+
const n = opts.session.manifest.stepCount;
|
|
855
|
+
await client.send("Runtime.evaluate", {
|
|
856
|
+
expression: `window.__trqSetStep && window.__trqSetStep(${n})`
|
|
857
|
+
}).catch(() => {
|
|
858
|
+
});
|
|
859
|
+
};
|
|
860
|
+
const broadcastMode = async (m) => {
|
|
861
|
+
await client.send("Runtime.evaluate", {
|
|
862
|
+
expression: `window.__trqMode = ${JSON.stringify(m)};`
|
|
863
|
+
}).catch(() => {
|
|
864
|
+
});
|
|
865
|
+
};
|
|
866
|
+
await client.send("Runtime.enable");
|
|
867
|
+
await client.send("Page.enable");
|
|
868
|
+
await applyConfig(client, config);
|
|
869
|
+
await client.send("Runtime.addBinding", { name: BINDING_NAME2 }).catch(() => {
|
|
870
|
+
});
|
|
871
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
872
|
+
source: `window.__trqMode = window.__trqMode || ${JSON.stringify(mode)};`
|
|
873
|
+
});
|
|
874
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: helpers });
|
|
875
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: bootstrap });
|
|
876
|
+
if (opts.killAnimations !== false) {
|
|
877
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_ANIMATION_KILL });
|
|
878
|
+
}
|
|
879
|
+
client.on("Runtime.bindingCalled", async (raw) => {
|
|
880
|
+
const event = raw;
|
|
881
|
+
if (event.name !== BINDING_NAME2) return;
|
|
882
|
+
try {
|
|
883
|
+
const incoming = JSON.parse(event.payload);
|
|
884
|
+
if (incoming.__trq === "ready") {
|
|
885
|
+
await broadcastStepCount2();
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (mode !== "recording") return;
|
|
889
|
+
const stamped = await opts.session.appendEvent(incoming);
|
|
890
|
+
opts.onRecordEvent?.(stamped);
|
|
891
|
+
await broadcastStepCount2();
|
|
892
|
+
} catch (err) {
|
|
893
|
+
console.error("\nFailed to handle event:", err.message);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
await client.send("Runtime.evaluate", {
|
|
897
|
+
expression: `window.__trqMode = window.__trqMode || ${JSON.stringify(mode)};`
|
|
898
|
+
}).catch(() => {
|
|
899
|
+
});
|
|
900
|
+
await client.send("Runtime.evaluate", { expression: helpers }).catch(() => {
|
|
901
|
+
});
|
|
902
|
+
await client.send("Runtime.evaluate", { expression: bootstrap }).catch(() => {
|
|
903
|
+
});
|
|
904
|
+
if (opts.killAnimations !== false) {
|
|
905
|
+
await client.send("Runtime.evaluate", { expression: INJECT_ANIMATION_KILL }).catch(() => {
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
const ctxCache = /* @__PURE__ */ new Map();
|
|
909
|
+
const ctxFor = (c) => {
|
|
910
|
+
let cached = ctxCache.get(c);
|
|
911
|
+
if (!cached) {
|
|
912
|
+
cached = cdpPageContext(c);
|
|
913
|
+
ctxCache.set(c, cached);
|
|
914
|
+
}
|
|
915
|
+
return cached;
|
|
916
|
+
};
|
|
917
|
+
const activeCtx = () => ctxFor(opts.getActiveClient?.() ?? client);
|
|
918
|
+
if (opts.startUrl && opts.startUrl !== "about:blank") {
|
|
919
|
+
await activeCtx().goto(opts.startUrl).catch(() => {
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
let prev = null;
|
|
923
|
+
for (let i = 0; i < total; i++) {
|
|
924
|
+
if (opts.shouldCancel?.()) return { replayed: i };
|
|
925
|
+
const e = events[i];
|
|
926
|
+
opts.onReplayProgress?.(i + 1, total, e, "start");
|
|
927
|
+
const ctx = activeCtx();
|
|
928
|
+
try {
|
|
929
|
+
const clientBefore = opts.getActiveClient?.() ?? client;
|
|
930
|
+
switch (e.type) {
|
|
931
|
+
case "navigate":
|
|
932
|
+
await replayNavigate(e, ctx, prev);
|
|
933
|
+
break;
|
|
934
|
+
case "click":
|
|
935
|
+
await replayClick(e, ctx);
|
|
936
|
+
break;
|
|
937
|
+
case "input":
|
|
938
|
+
await replayInput(e, ctx);
|
|
939
|
+
break;
|
|
940
|
+
case "key":
|
|
941
|
+
await replayKey(e, ctx);
|
|
942
|
+
break;
|
|
943
|
+
case "assert":
|
|
944
|
+
await replayAssert(e, ctx);
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
if (e.type === "click" && isAnchorClick(e)) {
|
|
948
|
+
const next = events[i + 1];
|
|
949
|
+
const expectedNextUrl = next && "url" in next ? next.url : void 0;
|
|
950
|
+
const nextSelectors = next && "selectors" in next ? next.selectors : void 0;
|
|
951
|
+
await waitForAnchorClickConsequence(
|
|
952
|
+
opts.getActiveClient,
|
|
953
|
+
client,
|
|
954
|
+
ctxFor,
|
|
955
|
+
clientBefore,
|
|
956
|
+
expectedNextUrl,
|
|
957
|
+
nextSelectors
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
const settle = e.type === "click" || e.type === "key" ? 60 : 20;
|
|
961
|
+
await new Promise((r) => setTimeout(r, settle));
|
|
962
|
+
prev = e;
|
|
963
|
+
opts.onReplayProgress?.(i + 1, total, e, "ok");
|
|
964
|
+
} catch (err) {
|
|
965
|
+
const msg = err.message;
|
|
966
|
+
opts.onReplayProgress?.(i + 1, total, e, "fail", msg);
|
|
967
|
+
if (opts.shouldPauseOnFail) {
|
|
968
|
+
const action = await opts.shouldPauseOnFail(i, total, e, msg);
|
|
969
|
+
if (action === "retry" || action === "skip") {
|
|
970
|
+
events = await opts.session.readEvents();
|
|
971
|
+
total = events.length;
|
|
972
|
+
if (action === "retry") {
|
|
973
|
+
const newIdx2 = events.findIndex((ev) => ev.id === e.id);
|
|
974
|
+
i = (newIdx2 === -1 ? i : newIdx2) - 1;
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
const newIdx = events.findIndex((ev) => ev.id === e.id);
|
|
978
|
+
if (newIdx !== -1) i = newIdx;
|
|
979
|
+
prev = events[i] ?? e;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return { replayed: i, failed: { step: i + 1, reason: msg } };
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
mode = "recording";
|
|
987
|
+
await broadcastMode("recording");
|
|
988
|
+
await broadcastStepCount2();
|
|
989
|
+
opts.onRecordingStart?.();
|
|
990
|
+
return { replayed: total };
|
|
991
|
+
}
|
|
992
|
+
init_page_context();
|
|
993
|
+
var __dirname4 = import_node_path.default.dirname((0, import_node_url.fileURLToPath)(__trq_meta_url__));
|
|
994
|
+
var BOOTSTRAP_PATH3 = import_node_path.default.join(__dirname4, "bootstrap.js");
|
|
995
|
+
async function loadBootstrap() {
|
|
996
|
+
return import_node_fs.promises.readFile(BOOTSTRAP_PATH3, "utf8");
|
|
997
|
+
}
|
|
998
|
+
async function loadReplayHelpers() {
|
|
999
|
+
return import_node_fs.promises.readFile(import_node_path.default.join(__dirname4, "replay-helpers.js"), "utf8");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/main/cdp-adapter.ts
|
|
1003
|
+
function createCDPClient(webContents) {
|
|
1004
|
+
const dbg = webContents.debugger;
|
|
1005
|
+
if (!dbg.isAttached()) {
|
|
1006
|
+
dbg.attach("1.3");
|
|
1007
|
+
}
|
|
1008
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
1009
|
+
const onMessage = (_event, method, params) => {
|
|
1010
|
+
if (process.env.TRQ_DEBUG_CDP) {
|
|
1011
|
+
console.log(`[cdp-adapter] \u2190 ${method}`);
|
|
1012
|
+
}
|
|
1013
|
+
const set = listeners.get(method);
|
|
1014
|
+
if (!set) return;
|
|
1015
|
+
for (const fn of set) {
|
|
1016
|
+
try {
|
|
1017
|
+
fn(params);
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
console.error(`[cdp-adapter] listener for ${method} threw:`, err);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
const onDetach = (_event, reason) => {
|
|
1024
|
+
const set = listeners.get("disconnected");
|
|
1025
|
+
if (!set) return;
|
|
1026
|
+
for (const fn of set) {
|
|
1027
|
+
try {
|
|
1028
|
+
fn({ reason });
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
console.error("[cdp-adapter] disconnected listener threw:", err);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
dbg.on("message", onMessage);
|
|
1035
|
+
dbg.on("detach", onDetach);
|
|
1036
|
+
return {
|
|
1037
|
+
send: (method, params) => dbg.sendCommand(method, params ?? {}),
|
|
1038
|
+
on: (event, listener) => {
|
|
1039
|
+
let set = listeners.get(event);
|
|
1040
|
+
if (!set) {
|
|
1041
|
+
set = /* @__PURE__ */ new Set();
|
|
1042
|
+
listeners.set(event, set);
|
|
1043
|
+
}
|
|
1044
|
+
set.add(listener);
|
|
1045
|
+
},
|
|
1046
|
+
off: (event, listener) => {
|
|
1047
|
+
listeners.get(event)?.delete(listener);
|
|
1048
|
+
},
|
|
1049
|
+
detach: () => {
|
|
1050
|
+
dbg.removeListener("message", onMessage);
|
|
1051
|
+
dbg.removeListener("detach", onDetach);
|
|
1052
|
+
listeners.clear();
|
|
1053
|
+
if (dbg.isAttached()) dbg.detach();
|
|
1054
|
+
},
|
|
1055
|
+
get attached() {
|
|
1056
|
+
return dbg.isAttached();
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/main/ipc.ts
|
|
1062
|
+
function emit(win, channel, payload) {
|
|
1063
|
+
if (win.isDestroyed()) return;
|
|
1064
|
+
win.webContents.send(channel, payload);
|
|
1065
|
+
}
|
|
1066
|
+
function broadcastStatus(state) {
|
|
1067
|
+
emit(state.win, "session:status", {
|
|
1068
|
+
mode: state.mode,
|
|
1069
|
+
session: state.session?.manifest.name ?? null,
|
|
1070
|
+
stepCount: state.session?.manifest.stepCount ?? 0
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
async function readEvents(dir) {
|
|
1074
|
+
const raw = await import_node_fs6.promises.readFile(import_node_path6.default.join(dir, "events.jsonl"), "utf8").catch(() => "");
|
|
1075
|
+
if (!raw.trim()) return [];
|
|
1076
|
+
return raw.trim().split("\n").map((line) => JSON.parse(line));
|
|
1077
|
+
}
|
|
1078
|
+
async function loadBlankSafe(view) {
|
|
1079
|
+
try {
|
|
1080
|
+
await view.webContents.loadURL("about:blank");
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
if (err.errno !== -3) throw err;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
var nextTabId = 1;
|
|
1086
|
+
function tabSummaries(state) {
|
|
1087
|
+
return state.tabs.map((t) => ({
|
|
1088
|
+
id: t.id,
|
|
1089
|
+
url: t.url,
|
|
1090
|
+
title: t.title,
|
|
1091
|
+
active: t.id === state.activeTabId
|
|
1092
|
+
}));
|
|
1093
|
+
}
|
|
1094
|
+
function broadcastTabs(state) {
|
|
1095
|
+
emit(state.win, "tabs:state", { tabs: tabSummaries(state) });
|
|
1096
|
+
}
|
|
1097
|
+
function wireUpTab(state, tab) {
|
|
1098
|
+
const wc = tab.view.webContents;
|
|
1099
|
+
wc.on("did-navigate", (_e, url) => {
|
|
1100
|
+
tab.url = url;
|
|
1101
|
+
broadcastTabs(state);
|
|
1102
|
+
if (tab.id === state.activeTabId) state.invalidateLayout();
|
|
1103
|
+
});
|
|
1104
|
+
wc.on("did-navigate-in-page", (_e, url) => {
|
|
1105
|
+
tab.url = url;
|
|
1106
|
+
broadcastTabs(state);
|
|
1107
|
+
});
|
|
1108
|
+
wc.on("page-title-updated", (_e, title) => {
|
|
1109
|
+
tab.title = title;
|
|
1110
|
+
broadcastTabs(state);
|
|
1111
|
+
});
|
|
1112
|
+
wc.on("did-finish-load", () => {
|
|
1113
|
+
if (tab.id === state.activeTabId) state.invalidateLayout();
|
|
1114
|
+
if (state.mode === "recording" && tab.client && state.recordingContext) {
|
|
1115
|
+
const bootstrap = state.recordingContext.bootstrap;
|
|
1116
|
+
const client = tab.client;
|
|
1117
|
+
void (async () => {
|
|
1118
|
+
try {
|
|
1119
|
+
await client.send("Runtime.evaluate", {
|
|
1120
|
+
expression: `(() => {
|
|
1121
|
+
if (document.getElementById('__trq_overlay')) return;
|
|
1122
|
+
window.__trqInstalled = false;
|
|
1123
|
+
})()`
|
|
1124
|
+
});
|
|
1125
|
+
await client.send("Runtime.evaluate", { expression: bootstrap });
|
|
1126
|
+
} catch {
|
|
1127
|
+
}
|
|
1128
|
+
})();
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
wc.on("did-stop-loading", () => {
|
|
1132
|
+
if (tab.id === state.activeTabId) state.invalidateLayout();
|
|
1133
|
+
});
|
|
1134
|
+
wc.on("console-message", (_e, level, message) => {
|
|
1135
|
+
if (tab.id !== state.activeTabId) return;
|
|
1136
|
+
const tag = ["log", "warn", "error"][level] ?? "log";
|
|
1137
|
+
console.log(`[page:${tag}] ${message}`);
|
|
1138
|
+
});
|
|
1139
|
+
wc.setWindowOpenHandler(({ url }) => {
|
|
1140
|
+
void createTab(state, url);
|
|
1141
|
+
return { action: "deny" };
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
async function createTab(state, url) {
|
|
1145
|
+
const view = new import_electron.BrowserView({
|
|
1146
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false }
|
|
1147
|
+
});
|
|
1148
|
+
const tab = {
|
|
1149
|
+
id: `tab_${nextTabId++}`,
|
|
1150
|
+
view,
|
|
1151
|
+
url,
|
|
1152
|
+
title: "",
|
|
1153
|
+
client: null
|
|
1154
|
+
};
|
|
1155
|
+
state.tabs.push(tab);
|
|
1156
|
+
wireUpTab(state, tab);
|
|
1157
|
+
if (state.mode === "recording" && state.recordingContext) {
|
|
1158
|
+
try {
|
|
1159
|
+
await loadBlankSafe(view);
|
|
1160
|
+
const client = createCDPClient(view.webContents);
|
|
1161
|
+
tab.client = client;
|
|
1162
|
+
await setupRecordingOnClient(client, state.recordingContext);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
console.warn("[createTab] failed to attach recording:", err.message);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (state.inspectActive && state.inspectAssets) {
|
|
1168
|
+
try {
|
|
1169
|
+
await loadBlankSafe(view);
|
|
1170
|
+
const client = createCDPClient(view.webContents);
|
|
1171
|
+
tab.client = client;
|
|
1172
|
+
const { bootstrap, helpers, config } = state.inspectAssets;
|
|
1173
|
+
await setupInspectOnClient(state, client, bootstrap, helpers, config);
|
|
1174
|
+
} catch (err) {
|
|
1175
|
+
console.warn("[createTab] failed to attach inspect:", err.message);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (state.mode === "replaying" && state.session) {
|
|
1179
|
+
try {
|
|
1180
|
+
await loadBlankSafe(view);
|
|
1181
|
+
const client = createCDPClient(view.webContents);
|
|
1182
|
+
tab.client = client;
|
|
1183
|
+
const config = await state.session.readConfig();
|
|
1184
|
+
await setupReplayClient(client, { config });
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
console.warn("[createTab] failed to attach replay client:", err.message);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
await switchToTab(state, tab.id);
|
|
1190
|
+
view.webContents.loadURL(url).catch(() => {
|
|
1191
|
+
});
|
|
1192
|
+
return tab;
|
|
1193
|
+
}
|
|
1194
|
+
async function switchToTab(state, id) {
|
|
1195
|
+
const next = state.tabs.find((t) => t.id === id);
|
|
1196
|
+
if (!next) return;
|
|
1197
|
+
for (const t of state.tabs) {
|
|
1198
|
+
try {
|
|
1199
|
+
state.win.removeBrowserView(t.view);
|
|
1200
|
+
} catch {
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
state.win.addBrowserView(next.view);
|
|
1204
|
+
state.activeTabId = id;
|
|
1205
|
+
state.view = next.view;
|
|
1206
|
+
state.recordingClient = next.client;
|
|
1207
|
+
state.invalidateLayout();
|
|
1208
|
+
broadcastTabs(state);
|
|
1209
|
+
}
|
|
1210
|
+
async function closeAuxiliaryTabs(state) {
|
|
1211
|
+
const primary = state.tabs[0];
|
|
1212
|
+
if (!primary) return;
|
|
1213
|
+
if (state.activeTabId !== primary.id) {
|
|
1214
|
+
await switchToTab(state, primary.id);
|
|
1215
|
+
}
|
|
1216
|
+
for (const t of state.tabs.slice(1)) {
|
|
1217
|
+
try {
|
|
1218
|
+
t.client?.detach();
|
|
1219
|
+
} catch {
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
state.win.removeBrowserView(t.view);
|
|
1223
|
+
} catch {
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
t.view.webContents.destroy?.();
|
|
1227
|
+
} catch {
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
state.tabs = [primary];
|
|
1231
|
+
broadcastTabs(state);
|
|
1232
|
+
}
|
|
1233
|
+
async function closeTab(state, id) {
|
|
1234
|
+
const idx = state.tabs.findIndex((t) => t.id === id);
|
|
1235
|
+
if (idx === -1) return;
|
|
1236
|
+
const tab = state.tabs[idx];
|
|
1237
|
+
if (state.tabs.length === 1) {
|
|
1238
|
+
try {
|
|
1239
|
+
tab.client?.detach();
|
|
1240
|
+
} catch {
|
|
1241
|
+
}
|
|
1242
|
+
tab.client = null;
|
|
1243
|
+
tab.url = "about:blank";
|
|
1244
|
+
tab.title = "";
|
|
1245
|
+
await loadBlankSafe(tab.view);
|
|
1246
|
+
broadcastTabs(state);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
tab.client?.detach();
|
|
1251
|
+
} catch {
|
|
1252
|
+
}
|
|
1253
|
+
try {
|
|
1254
|
+
state.win.removeBrowserView(tab.view);
|
|
1255
|
+
} catch {
|
|
1256
|
+
}
|
|
1257
|
+
try {
|
|
1258
|
+
tab.view.webContents.destroy?.();
|
|
1259
|
+
} catch {
|
|
1260
|
+
}
|
|
1261
|
+
state.tabs.splice(idx, 1);
|
|
1262
|
+
if (state.activeTabId === id) {
|
|
1263
|
+
const neighbor = state.tabs[Math.max(0, idx - 1)] ?? state.tabs[state.tabs.length - 1];
|
|
1264
|
+
await switchToTab(state, neighbor.id);
|
|
1265
|
+
} else {
|
|
1266
|
+
broadcastTabs(state);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function detachDebugger(state) {
|
|
1270
|
+
try {
|
|
1271
|
+
state.recordingClient?.detach();
|
|
1272
|
+
} catch {
|
|
1273
|
+
}
|
|
1274
|
+
state.recordingClient = null;
|
|
1275
|
+
try {
|
|
1276
|
+
const dbg = state.view.webContents.debugger;
|
|
1277
|
+
if (dbg.isAttached()) dbg.detach();
|
|
1278
|
+
} catch {
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
async function clearBrowserState(state) {
|
|
1282
|
+
try {
|
|
1283
|
+
await state.view.webContents.session.clearStorageData({
|
|
1284
|
+
storages: [
|
|
1285
|
+
"cookies",
|
|
1286
|
+
"localstorage",
|
|
1287
|
+
"sessionstorage",
|
|
1288
|
+
"indexdb",
|
|
1289
|
+
"cachestorage",
|
|
1290
|
+
"serviceworkers",
|
|
1291
|
+
"shadercache",
|
|
1292
|
+
"websql"
|
|
1293
|
+
]
|
|
1294
|
+
});
|
|
1295
|
+
await state.view.webContents.session.clearCache();
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
console.warn("[clearBrowserState] failed:", err.message);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
async function startRecording(state, name) {
|
|
1301
|
+
if (state.inspectActive) {
|
|
1302
|
+
await stopInspect(state);
|
|
1303
|
+
}
|
|
1304
|
+
if (state.mode !== "idle") {
|
|
1305
|
+
throw new Error(`Cannot start recording while ${state.mode}`);
|
|
1306
|
+
}
|
|
1307
|
+
await closeAuxiliaryTabs(state);
|
|
1308
|
+
await clearBrowserState(state);
|
|
1309
|
+
const session = await Session.load(state.sessionsDir, name);
|
|
1310
|
+
const config = await session.readConfig();
|
|
1311
|
+
state.lockedViewport = config.viewport ? { width: config.viewport.width, height: config.viewport.height } : null;
|
|
1312
|
+
const bootstrap = await loadBootstrap();
|
|
1313
|
+
detachDebugger(state);
|
|
1314
|
+
await loadBlankSafe(state.view);
|
|
1315
|
+
const client = createCDPClient(state.view.webContents);
|
|
1316
|
+
const ctx = {
|
|
1317
|
+
session,
|
|
1318
|
+
bootstrap,
|
|
1319
|
+
clients: /* @__PURE__ */ new Set(),
|
|
1320
|
+
onEvent: (e) => {
|
|
1321
|
+
try {
|
|
1322
|
+
emit(state.win, "session:event", e);
|
|
1323
|
+
broadcastStatus(state);
|
|
1324
|
+
} catch {
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
config
|
|
1328
|
+
};
|
|
1329
|
+
await setupRecordingOnClient(client, ctx);
|
|
1330
|
+
state.session = session;
|
|
1331
|
+
state.recordingClient = client;
|
|
1332
|
+
state.recordingContext = ctx;
|
|
1333
|
+
const activeTab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
1334
|
+
if (activeTab) activeTab.client = client;
|
|
1335
|
+
state.mode = "recording";
|
|
1336
|
+
broadcastStatus(state);
|
|
1337
|
+
emit(state.win, "sessions:changed", void 0);
|
|
1338
|
+
const startUrl = session.manifest.initialUrl;
|
|
1339
|
+
if (startUrl && startUrl !== "about:blank") {
|
|
1340
|
+
state.view.webContents.loadURL(startUrl).catch(() => {
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
async function setupInspectOnClient(state, client, bootstrap, helpers, config) {
|
|
1345
|
+
await client.send("Runtime.enable");
|
|
1346
|
+
await client.send("Page.enable");
|
|
1347
|
+
if (config) {
|
|
1348
|
+
await applyConfig(client, config);
|
|
1349
|
+
}
|
|
1350
|
+
await client.send("Runtime.addBinding", { name: "__trqEmit" }).catch(() => {
|
|
1351
|
+
});
|
|
1352
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", {
|
|
1353
|
+
source: 'window.__trqMode = "idle"; window.__trqInspect = true;'
|
|
1354
|
+
});
|
|
1355
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: helpers });
|
|
1356
|
+
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: bootstrap });
|
|
1357
|
+
client.on("Runtime.bindingCalled", (raw) => {
|
|
1358
|
+
const e = raw;
|
|
1359
|
+
if (e.name !== "__trqEmit") return;
|
|
1360
|
+
try {
|
|
1361
|
+
const payload = JSON.parse(e.payload);
|
|
1362
|
+
if (payload.__trq === "hover" && payload.selectors && payload.target) {
|
|
1363
|
+
emit(state.win, "inspect:hover", {
|
|
1364
|
+
selectors: payload.selectors,
|
|
1365
|
+
target: payload.target
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
} catch {
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
await client.send("Runtime.evaluate", {
|
|
1372
|
+
expression: 'window.__trqMode = "idle"; window.__trqInspect = true;'
|
|
1373
|
+
}).catch(() => {
|
|
1374
|
+
});
|
|
1375
|
+
await client.send("Runtime.evaluate", { expression: helpers }).catch(() => {
|
|
1376
|
+
});
|
|
1377
|
+
await client.send("Runtime.evaluate", { expression: bootstrap }).catch(() => {
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
async function startInspect(state, sessionName) {
|
|
1381
|
+
if (state.inspectActive) return;
|
|
1382
|
+
if (state.mode !== "idle") {
|
|
1383
|
+
throw new Error(`Cannot inspect while ${state.mode}`);
|
|
1384
|
+
}
|
|
1385
|
+
if (!sessionName) {
|
|
1386
|
+
throw new Error("Inspect requires a selected session");
|
|
1387
|
+
}
|
|
1388
|
+
let session;
|
|
1389
|
+
try {
|
|
1390
|
+
session = await Session.load(state.sessionsDir, sessionName);
|
|
1391
|
+
} catch {
|
|
1392
|
+
throw new Error(`Session not found: ${sessionName}`);
|
|
1393
|
+
}
|
|
1394
|
+
const config = await session.readConfig();
|
|
1395
|
+
state.lockedViewport = config.viewport ? { width: config.viewport.width, height: config.viewport.height } : null;
|
|
1396
|
+
await closeAuxiliaryTabs(state);
|
|
1397
|
+
detachDebugger(state);
|
|
1398
|
+
await clearBrowserState(state);
|
|
1399
|
+
await loadBlankSafe(state.view);
|
|
1400
|
+
const bootstrap = await loadBootstrap();
|
|
1401
|
+
const helpers = await loadReplayHelpers();
|
|
1402
|
+
state.inspectAssets = { bootstrap, helpers, config };
|
|
1403
|
+
const client = createCDPClient(state.view.webContents);
|
|
1404
|
+
state.recordingClient = client;
|
|
1405
|
+
const activeTab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
1406
|
+
if (activeTab) activeTab.client = client;
|
|
1407
|
+
await setupInspectOnClient(state, client, bootstrap, helpers, config);
|
|
1408
|
+
state.inspectActive = true;
|
|
1409
|
+
state.cancelReplay = false;
|
|
1410
|
+
state.runToEnd = false;
|
|
1411
|
+
state.replayCursor = null;
|
|
1412
|
+
state.stepResolver = null;
|
|
1413
|
+
state.mode = "paused";
|
|
1414
|
+
emit(state.win, "inspect:state", { active: true });
|
|
1415
|
+
broadcastStatus(state);
|
|
1416
|
+
state.inspectorOpen = true;
|
|
1417
|
+
state.invalidateLayout();
|
|
1418
|
+
state.inspectLoopPromise = (async () => {
|
|
1419
|
+
try {
|
|
1420
|
+
await runReplayOnClient({
|
|
1421
|
+
client,
|
|
1422
|
+
session,
|
|
1423
|
+
startUrl: session.manifest.initialUrl ?? void 0,
|
|
1424
|
+
shouldCancel: () => state.cancelReplay,
|
|
1425
|
+
// Follow into tabs spawned during the inspect step-through. createTab's
|
|
1426
|
+
// inspect branch above attaches a CDP client + injects helpers, then
|
|
1427
|
+
// switchToTab updates state.recordingClient — this getter picks it up.
|
|
1428
|
+
getActiveClient: () => state.recordingClient ?? client,
|
|
1429
|
+
shouldWait: async (idx, _total, e) => {
|
|
1430
|
+
state.replayCursor = {
|
|
1431
|
+
idx,
|
|
1432
|
+
total: _total,
|
|
1433
|
+
eventId: e.id,
|
|
1434
|
+
eventType: e.type
|
|
1435
|
+
};
|
|
1436
|
+
emit(state.win, "replay:cursor", {
|
|
1437
|
+
idx,
|
|
1438
|
+
total: _total,
|
|
1439
|
+
eventId: e.id,
|
|
1440
|
+
eventType: e.type
|
|
1441
|
+
});
|
|
1442
|
+
if (state.runToEnd) return;
|
|
1443
|
+
await new Promise((resolve) => {
|
|
1444
|
+
state.stepResolver = resolve;
|
|
1445
|
+
});
|
|
1446
|
+
state.stepResolver = null;
|
|
1447
|
+
},
|
|
1448
|
+
onProgress: (idx, total, e, status, detail) => {
|
|
1449
|
+
emit(state.win, "replay:progress", {
|
|
1450
|
+
current: idx,
|
|
1451
|
+
total,
|
|
1452
|
+
eventType: e.type,
|
|
1453
|
+
status,
|
|
1454
|
+
detail
|
|
1455
|
+
});
|
|
1456
|
+
}
|
|
1457
|
+
});
|
|
1458
|
+
} catch (err) {
|
|
1459
|
+
console.error("[inspect-debug] loop error:", err.message);
|
|
1460
|
+
} finally {
|
|
1461
|
+
const stillOurs = state.recordingClient === client;
|
|
1462
|
+
state.replayCursor = null;
|
|
1463
|
+
state.stepResolver = null;
|
|
1464
|
+
state.runToEnd = false;
|
|
1465
|
+
emit(state.win, "replay:cursor", { idx: 0, total: 0, eventId: null, eventType: null });
|
|
1466
|
+
if (stillOurs) {
|
|
1467
|
+
try {
|
|
1468
|
+
await client.send("Runtime.evaluate", {
|
|
1469
|
+
expression: `(() => {
|
|
1470
|
+
window.__trqInspect = false;
|
|
1471
|
+
window.__trqMode = 'idle';
|
|
1472
|
+
for (const id of ['__trq_overlay', '__trq_highlight', '__trq_assert_menu']) {
|
|
1473
|
+
document.getElementById(id)?.remove();
|
|
1474
|
+
}
|
|
1475
|
+
window.__trqInstalled = false;
|
|
1476
|
+
})()`
|
|
1477
|
+
});
|
|
1478
|
+
} catch {
|
|
1479
|
+
}
|
|
1480
|
+
detachDebugger(state);
|
|
1481
|
+
exitInspectLayout(state);
|
|
1482
|
+
state.inspectActive = false;
|
|
1483
|
+
state.cancelReplay = false;
|
|
1484
|
+
state.lockedViewport = null;
|
|
1485
|
+
state.mode = "idle";
|
|
1486
|
+
emit(state.win, "inspect:state", { active: false });
|
|
1487
|
+
broadcastStatus(state);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
})();
|
|
1491
|
+
}
|
|
1492
|
+
async function stopInspect(state) {
|
|
1493
|
+
if (!state.inspectActive) return;
|
|
1494
|
+
state.cancelReplay = true;
|
|
1495
|
+
state.stepResolver?.();
|
|
1496
|
+
state.stepResolver = null;
|
|
1497
|
+
if (state.inspectLoopPromise) {
|
|
1498
|
+
await state.inspectLoopPromise;
|
|
1499
|
+
state.inspectLoopPromise = null;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
function stepInspect(state) {
|
|
1503
|
+
if (state.mode !== "paused") return;
|
|
1504
|
+
const r = state.stepResolver;
|
|
1505
|
+
if (!r) return;
|
|
1506
|
+
state.stepResolver = null;
|
|
1507
|
+
r();
|
|
1508
|
+
}
|
|
1509
|
+
function continueInspect(state) {
|
|
1510
|
+
if (state.mode !== "paused") return;
|
|
1511
|
+
state.runToEnd = true;
|
|
1512
|
+
const r = state.stepResolver;
|
|
1513
|
+
if (r) {
|
|
1514
|
+
state.stepResolver = null;
|
|
1515
|
+
r();
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
function exitInspectLayout(state) {
|
|
1519
|
+
state.inspectorOpen = false;
|
|
1520
|
+
state.inspectorCollapsed = false;
|
|
1521
|
+
state.invalidateLayout();
|
|
1522
|
+
}
|
|
1523
|
+
async function highlightSelector(state, selectors) {
|
|
1524
|
+
if (!state.inspectActive || !state.recordingClient) return false;
|
|
1525
|
+
try {
|
|
1526
|
+
const r = await state.recordingClient.send("Runtime.evaluate", {
|
|
1527
|
+
expression: `window.__trqHighlight && window.__trqHighlight(${JSON.stringify(selectors)})`,
|
|
1528
|
+
returnByValue: true
|
|
1529
|
+
});
|
|
1530
|
+
return r.result?.value ?? false;
|
|
1531
|
+
} catch {
|
|
1532
|
+
return false;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
async function stopRecording(state) {
|
|
1536
|
+
if (state.mode !== "recording") return;
|
|
1537
|
+
for (const t of state.tabs) {
|
|
1538
|
+
try {
|
|
1539
|
+
t.client?.detach();
|
|
1540
|
+
} catch {
|
|
1541
|
+
}
|
|
1542
|
+
t.client = null;
|
|
1543
|
+
}
|
|
1544
|
+
detachDebugger(state);
|
|
1545
|
+
state.recordingContext = null;
|
|
1546
|
+
state.lockedViewport = null;
|
|
1547
|
+
state.mode = "idle";
|
|
1548
|
+
broadcastStatus(state);
|
|
1549
|
+
emit(state.win, "sessions:changed", void 0);
|
|
1550
|
+
}
|
|
1551
|
+
function pauseAtFailure(state, idx, total, eventId, reason) {
|
|
1552
|
+
state.pauseFailure = { step: idx + 1, total, eventId, reason };
|
|
1553
|
+
state.invalidateLayout();
|
|
1554
|
+
emit(state.win, "pause:failure", state.pauseFailure);
|
|
1555
|
+
console.log(`[pause] step ${idx + 1}/${total}: ${reason}`);
|
|
1556
|
+
return new Promise((resolve) => {
|
|
1557
|
+
state.pauseResolver = (action) => {
|
|
1558
|
+
state.pauseFailure = null;
|
|
1559
|
+
state.pauseResolver = null;
|
|
1560
|
+
state.invalidateLayout();
|
|
1561
|
+
emit(state.win, "pause:failure", null);
|
|
1562
|
+
resolve(action);
|
|
1563
|
+
};
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
async function play(state, name) {
|
|
1567
|
+
if (state.inspectActive) {
|
|
1568
|
+
await stopInspect(state);
|
|
1569
|
+
}
|
|
1570
|
+
if (state.mode !== "idle") throw new Error(`Cannot play while ${state.mode}`);
|
|
1571
|
+
await closeAuxiliaryTabs(state);
|
|
1572
|
+
const session = await Session.load(state.sessionsDir, name);
|
|
1573
|
+
const config = await session.readConfig();
|
|
1574
|
+
state.lockedViewport = config.viewport ? { width: config.viewport.width, height: config.viewport.height } : null;
|
|
1575
|
+
state.session = session;
|
|
1576
|
+
state.mode = "replaying";
|
|
1577
|
+
state.cancelReplay = false;
|
|
1578
|
+
broadcastStatus(state);
|
|
1579
|
+
detachDebugger(state);
|
|
1580
|
+
await clearBrowserState(state);
|
|
1581
|
+
await loadBlankSafe(state.view);
|
|
1582
|
+
const client = createCDPClient(state.view.webContents);
|
|
1583
|
+
state.recordingClient = client;
|
|
1584
|
+
const activeTab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
1585
|
+
if (activeTab) activeTab.client = client;
|
|
1586
|
+
try {
|
|
1587
|
+
const result = await runReplayOnClient({
|
|
1588
|
+
client,
|
|
1589
|
+
session,
|
|
1590
|
+
startUrl: session.manifest.initialUrl ?? void 0,
|
|
1591
|
+
shouldCancel: () => state.cancelReplay,
|
|
1592
|
+
// Follow into new tabs as the recording did. createTab during replay
|
|
1593
|
+
// mode attaches a CDP client + injects helpers, and switchToTab
|
|
1594
|
+
// updates state.recordingClient — so this getter naturally picks up
|
|
1595
|
+
// the active tab's client between events.
|
|
1596
|
+
getActiveClient: () => state.recordingClient ?? client,
|
|
1597
|
+
shouldPauseOnFail: (idx, total, e, reason) => pauseAtFailure(state, idx, total, e.id, reason),
|
|
1598
|
+
onProgress: (idx, total, e, status, detail) => {
|
|
1599
|
+
emit(state.win, "replay:progress", {
|
|
1600
|
+
current: idx,
|
|
1601
|
+
total,
|
|
1602
|
+
eventType: e.type,
|
|
1603
|
+
status,
|
|
1604
|
+
detail
|
|
1605
|
+
});
|
|
1606
|
+
if (status === "start") {
|
|
1607
|
+
emit(state.win, "log:line", { level: "info", text: `\u25B8 ${idx}/${total} ${e.type}` });
|
|
1608
|
+
} else if (status === "fail") {
|
|
1609
|
+
emit(state.win, "log:line", { level: "error", text: `\u2717 step ${idx}: ${detail ?? "failed"}` });
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
const summary = result.failed ? `[replay] \u2717 step ${result.failed.step}/${result.total}: ${result.failed.reason}` : `[replay] \u2713 ${result.completed}/${result.total}`;
|
|
1614
|
+
console.log(summary);
|
|
1615
|
+
emit(state.win, "log:line", {
|
|
1616
|
+
level: result.failed ? "error" : "info",
|
|
1617
|
+
text: summary
|
|
1618
|
+
});
|
|
1619
|
+
} finally {
|
|
1620
|
+
state.mode = "idle";
|
|
1621
|
+
broadcastStatus(state);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
async function resume(state, name) {
|
|
1625
|
+
if (state.inspectActive) {
|
|
1626
|
+
await stopInspect(state);
|
|
1627
|
+
}
|
|
1628
|
+
if (state.mode !== "idle") throw new Error(`Cannot resume while ${state.mode}`);
|
|
1629
|
+
await closeAuxiliaryTabs(state);
|
|
1630
|
+
const session = await Session.load(state.sessionsDir, name);
|
|
1631
|
+
const config = await session.readConfig();
|
|
1632
|
+
const bootstrap = await loadBootstrap();
|
|
1633
|
+
state.lockedViewport = config.viewport ? { width: config.viewport.width, height: config.viewport.height } : null;
|
|
1634
|
+
state.session = session;
|
|
1635
|
+
state.mode = "replaying";
|
|
1636
|
+
state.cancelReplay = false;
|
|
1637
|
+
broadcastStatus(state);
|
|
1638
|
+
detachDebugger(state);
|
|
1639
|
+
await clearBrowserState(state);
|
|
1640
|
+
await loadBlankSafe(state.view);
|
|
1641
|
+
const client = createCDPClient(state.view.webContents);
|
|
1642
|
+
state.recordingClient = client;
|
|
1643
|
+
const activeTab = state.tabs.find((t) => t.id === state.activeTabId);
|
|
1644
|
+
if (activeTab) activeTab.client = client;
|
|
1645
|
+
try {
|
|
1646
|
+
const result = await runResumeOnClient({
|
|
1647
|
+
client,
|
|
1648
|
+
session,
|
|
1649
|
+
startUrl: session.manifest.initialUrl ?? void 0,
|
|
1650
|
+
shouldCancel: () => state.cancelReplay,
|
|
1651
|
+
getActiveClient: () => state.recordingClient ?? client,
|
|
1652
|
+
shouldPauseOnFail: (idx, total, e, reason) => pauseAtFailure(state, idx, total, e.id, reason),
|
|
1653
|
+
onReplayProgress: (idx, total, e, status, detail) => {
|
|
1654
|
+
emit(state.win, "replay:progress", {
|
|
1655
|
+
current: idx,
|
|
1656
|
+
total,
|
|
1657
|
+
eventType: e.type,
|
|
1658
|
+
status,
|
|
1659
|
+
detail
|
|
1660
|
+
});
|
|
1661
|
+
if (status === "start") {
|
|
1662
|
+
emit(state.win, "log:line", { level: "info", text: `\u25B8 ${idx}/${total} ${e.type}` });
|
|
1663
|
+
} else if (status === "fail") {
|
|
1664
|
+
emit(state.win, "log:line", { level: "error", text: `\u2717 step ${idx}: ${detail ?? "failed"}` });
|
|
1665
|
+
}
|
|
1666
|
+
},
|
|
1667
|
+
onRecordingStart: () => {
|
|
1668
|
+
emit(state.win, "log:line", {
|
|
1669
|
+
level: "info",
|
|
1670
|
+
text: "\u25B6 replay complete \u2014 now recording. close the window or click Stop to finish."
|
|
1671
|
+
});
|
|
1672
|
+
state.mode = "recording";
|
|
1673
|
+
broadcastStatus(state);
|
|
1674
|
+
const ctx = {
|
|
1675
|
+
session: state.session,
|
|
1676
|
+
bootstrap,
|
|
1677
|
+
clients: /* @__PURE__ */ new Set([client]),
|
|
1678
|
+
onEvent: (e) => {
|
|
1679
|
+
emit(state.win, "session:event", e);
|
|
1680
|
+
broadcastStatus(state);
|
|
1681
|
+
},
|
|
1682
|
+
config
|
|
1683
|
+
};
|
|
1684
|
+
state.recordingContext = ctx;
|
|
1685
|
+
void Promise.all(
|
|
1686
|
+
state.tabs.slice(1).filter((t) => !!t.client).map((t) => setupRecordingOnClient(t.client, ctx))
|
|
1687
|
+
).catch((err) => {
|
|
1688
|
+
console.warn("[resume] aux-tab recording setup failed:", err.message);
|
|
1689
|
+
});
|
|
1690
|
+
},
|
|
1691
|
+
onRecordEvent: (e) => {
|
|
1692
|
+
emit(state.win, "session:event", e);
|
|
1693
|
+
broadcastStatus(state);
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
if (result.failed) {
|
|
1697
|
+
const summary = `[resume] \u2717 step ${result.failed.step}/${result.total}: ${result.failed.reason}`;
|
|
1698
|
+
console.log(summary);
|
|
1699
|
+
emit(state.win, "log:line", { level: "error", text: summary });
|
|
1700
|
+
state.mode = "idle";
|
|
1701
|
+
broadcastStatus(state);
|
|
1702
|
+
} else {
|
|
1703
|
+
console.log(`[resume] \u2713 replay phase complete (${result.completed}/${result.total}) \u2014 recording`);
|
|
1704
|
+
}
|
|
1705
|
+
} catch (err) {
|
|
1706
|
+
state.mode = "idle";
|
|
1707
|
+
broadcastStatus(state);
|
|
1708
|
+
throw err;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
function handlers(state) {
|
|
1712
|
+
return {
|
|
1713
|
+
"sessions:list": async () => Session.list(state.sessionsDir),
|
|
1714
|
+
"sessions:create": async (s, { name, startUrl }) => {
|
|
1715
|
+
const session = await Session.create(s.sessionsDir, name, startUrl);
|
|
1716
|
+
await clearBrowserState(s);
|
|
1717
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1718
|
+
return session.manifest;
|
|
1719
|
+
},
|
|
1720
|
+
"sessions:delete": async (s, { name }) => {
|
|
1721
|
+
if (s.session?.manifest.name === name && s.mode === "recording") {
|
|
1722
|
+
await stopRecording(s);
|
|
1723
|
+
}
|
|
1724
|
+
await import_node_fs6.promises.rm(import_node_path6.default.join(s.sessionsDir, name), { recursive: true, force: true });
|
|
1725
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1726
|
+
},
|
|
1727
|
+
"sessions:events": async (s, { name }) => readEvents(import_node_path6.default.join(s.sessionsDir, name)),
|
|
1728
|
+
"recording:start": async (s, { name }) => startRecording(s, name),
|
|
1729
|
+
"recording:stop": async (s) => stopRecording(s),
|
|
1730
|
+
"replay:play": async (s, { name }) => play(s, name),
|
|
1731
|
+
"replay:resume": async (s, { name }) => resume(s, name),
|
|
1732
|
+
"replay:stop": async (s) => {
|
|
1733
|
+
s.cancelReplay = true;
|
|
1734
|
+
},
|
|
1735
|
+
// Pause-at-failure resolvers. Each unblocks the loop's awaited promise
|
|
1736
|
+
// with the chosen action. No-op if the loop isn't currently paused.
|
|
1737
|
+
"pause:retry": async (s) => {
|
|
1738
|
+
s.pauseResolver?.("retry");
|
|
1739
|
+
},
|
|
1740
|
+
"pause:skip": async (s) => {
|
|
1741
|
+
s.pauseResolver?.("skip");
|
|
1742
|
+
},
|
|
1743
|
+
"pause:stop": async (s) => {
|
|
1744
|
+
s.pauseResolver?.("stop");
|
|
1745
|
+
},
|
|
1746
|
+
"pause:editFailed": async (s) => {
|
|
1747
|
+
if (!s.pauseFailure || !s.session) return null;
|
|
1748
|
+
return { sessionName: s.session.manifest.name, eventId: s.pauseFailure.eventId };
|
|
1749
|
+
},
|
|
1750
|
+
// Run the page-side scorer against the failed event's recorded target.
|
|
1751
|
+
// Only valid while paused — the live page is in the state it was at the
|
|
1752
|
+
// moment of failure, so DOM candidates are meaningful.
|
|
1753
|
+
"events:suggestSelectors": async (s, { eventId }) => {
|
|
1754
|
+
if (!s.pauseFailure || !s.session || !s.recordingClient) return [];
|
|
1755
|
+
if (eventId !== s.pauseFailure.eventId) return [];
|
|
1756
|
+
const events = await readEvents(import_node_path6.default.join(s.sessionsDir, s.session.manifest.name));
|
|
1757
|
+
const ev = events.find((e) => e.id === eventId);
|
|
1758
|
+
const target = ev?.target;
|
|
1759
|
+
if (!target) return [];
|
|
1760
|
+
try {
|
|
1761
|
+
const r = await s.recordingClient.send("Runtime.evaluate", {
|
|
1762
|
+
expression: `JSON.stringify(window.__trqSuggest && window.__trqSuggest(${JSON.stringify(target)}, 3) || [])`,
|
|
1763
|
+
returnByValue: true
|
|
1764
|
+
});
|
|
1765
|
+
const raw = r.result?.value ?? "[]";
|
|
1766
|
+
return JSON.parse(raw);
|
|
1767
|
+
} catch {
|
|
1768
|
+
return [];
|
|
1769
|
+
}
|
|
1770
|
+
},
|
|
1771
|
+
// Activate the page-side click-to-capture mode. The picked envelope
|
|
1772
|
+
// comes back through the __trqEmit binding; we forward it to the
|
|
1773
|
+
// renderer as 'pick:result'. Caller controls UI (drawer minimize, etc).
|
|
1774
|
+
"events:pickFromPage": async (s) => {
|
|
1775
|
+
if (!s.pauseFailure || !s.recordingClient) return;
|
|
1776
|
+
const client = s.recordingClient;
|
|
1777
|
+
await client.send("Runtime.addBinding", { name: "__trqEmit" }).catch(() => {
|
|
1778
|
+
});
|
|
1779
|
+
const handler = (raw) => {
|
|
1780
|
+
const e = raw;
|
|
1781
|
+
if (e.name !== "__trqEmit") return;
|
|
1782
|
+
try {
|
|
1783
|
+
const p = JSON.parse(e.payload);
|
|
1784
|
+
if (p.__trq === "pick") {
|
|
1785
|
+
emit(s.win, "pick:result", p);
|
|
1786
|
+
client.off?.("Runtime.bindingCalled", handler);
|
|
1787
|
+
} else if (p.__trq === "pick-cancel") {
|
|
1788
|
+
emit(s.win, "pick:result", null);
|
|
1789
|
+
client.off?.("Runtime.bindingCalled", handler);
|
|
1790
|
+
}
|
|
1791
|
+
} catch {
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
client.on("Runtime.bindingCalled", handler);
|
|
1795
|
+
s.win.setBrowserView(s.view);
|
|
1796
|
+
s.win.emit("resize");
|
|
1797
|
+
await client.send("Runtime.evaluate", {
|
|
1798
|
+
expression: "window.__trqStartPick && window.__trqStartPick()"
|
|
1799
|
+
}).catch(() => {
|
|
1800
|
+
});
|
|
1801
|
+
},
|
|
1802
|
+
"events:cancelPick": async (s) => {
|
|
1803
|
+
if (!s.recordingClient) return;
|
|
1804
|
+
await s.recordingClient.send("Runtime.evaluate", {
|
|
1805
|
+
expression: "window.__trqStopPick && window.__trqStopPick()"
|
|
1806
|
+
}).catch(() => {
|
|
1807
|
+
});
|
|
1808
|
+
},
|
|
1809
|
+
"view:navigate": async (s, { url }) => {
|
|
1810
|
+
await s.view.webContents.loadURL(url);
|
|
1811
|
+
},
|
|
1812
|
+
"view:setVisible": async (s, { visible }) => {
|
|
1813
|
+
if (visible) {
|
|
1814
|
+
s.win.setBrowserView(s.view);
|
|
1815
|
+
s.win.emit("resize");
|
|
1816
|
+
} else {
|
|
1817
|
+
s.win.setBrowserView(null);
|
|
1818
|
+
}
|
|
1819
|
+
},
|
|
1820
|
+
"view:layout": async (s, { sidebarW, eventsH }) => {
|
|
1821
|
+
s.layout.sidebarW = sidebarW;
|
|
1822
|
+
s.layout.eventsH = eventsH;
|
|
1823
|
+
s.invalidateLayout();
|
|
1824
|
+
},
|
|
1825
|
+
"config:get": async (s, { name }) => {
|
|
1826
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1827
|
+
return session.readConfig();
|
|
1828
|
+
},
|
|
1829
|
+
"config:set": async (s, { name, config }) => {
|
|
1830
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1831
|
+
await session.writeConfig(config);
|
|
1832
|
+
},
|
|
1833
|
+
"inspect:start": async (s, args) => startInspect(s, args?.name),
|
|
1834
|
+
"inspect:stop": async (s) => stopInspect(s),
|
|
1835
|
+
"inspect:highlight": async (s, { selectors }) => highlightSelector(s, selectors),
|
|
1836
|
+
"inspect:step": async (s) => stepInspect(s),
|
|
1837
|
+
"inspect:continue": async (s) => continueInspect(s),
|
|
1838
|
+
"inspect:toggleCollapsed": async (s) => {
|
|
1839
|
+
s.inspectorCollapsed = !s.inspectorCollapsed;
|
|
1840
|
+
s.invalidateLayout();
|
|
1841
|
+
emit(s.win, "inspect:state", { active: s.inspectActive, collapsed: s.inspectorCollapsed });
|
|
1842
|
+
return { collapsed: s.inspectorCollapsed };
|
|
1843
|
+
},
|
|
1844
|
+
"tabs:list": async (s) => tabSummaries(s),
|
|
1845
|
+
"tabs:switch": async (s, { id }) => switchToTab(s, id),
|
|
1846
|
+
"tabs:close": async (s, { id }) => closeTab(s, id),
|
|
1847
|
+
"events:replace": async (s, { name, id, patch }) => {
|
|
1848
|
+
if (s.mode === "recording" && s.session?.manifest.name === name) {
|
|
1849
|
+
throw new Error("Cannot edit events while recording");
|
|
1850
|
+
}
|
|
1851
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1852
|
+
const stamped = await session.replaceEvent(id, patch);
|
|
1853
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1854
|
+
return stamped;
|
|
1855
|
+
},
|
|
1856
|
+
"events:delete": async (s, { name, id }) => {
|
|
1857
|
+
if (s.mode === "recording" && s.session?.manifest.name === name) {
|
|
1858
|
+
throw new Error("Cannot delete events while recording");
|
|
1859
|
+
}
|
|
1860
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1861
|
+
await session.removeEvent(id);
|
|
1862
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1863
|
+
},
|
|
1864
|
+
"events:insertAt": async (s, { name, idx, event }) => {
|
|
1865
|
+
if (s.mode === "recording" && s.session?.manifest.name === name) {
|
|
1866
|
+
throw new Error("Cannot insert events while recording");
|
|
1867
|
+
}
|
|
1868
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1869
|
+
const stamped = await session.insertEventAt(idx, event);
|
|
1870
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1871
|
+
return stamped;
|
|
1872
|
+
},
|
|
1873
|
+
"sessions:branch": async (s, { name, newName, throughStep }) => {
|
|
1874
|
+
const session = await Session.load(s.sessionsDir, name);
|
|
1875
|
+
const branched = await session.branch(s.sessionsDir, newName, throughStep);
|
|
1876
|
+
emit(s.win, "sessions:changed", void 0);
|
|
1877
|
+
return branched.manifest;
|
|
1878
|
+
},
|
|
1879
|
+
"ui:eventContextMenu": async (s, { sessionName, eventId, eventIdx, total }) => {
|
|
1880
|
+
const send = (action) => {
|
|
1881
|
+
emit(s.win, "ui:contextMenuChoice", { sessionName, eventId, eventIdx, action });
|
|
1882
|
+
};
|
|
1883
|
+
const menu = import_electron.Menu.buildFromTemplate([
|
|
1884
|
+
{ label: "Edit\u2026", click: () => send("edit") },
|
|
1885
|
+
{ label: "Delete", click: () => send("delete") },
|
|
1886
|
+
{ type: "separator" },
|
|
1887
|
+
{ label: "Insert event above\u2026", click: () => send("insertAbove") },
|
|
1888
|
+
{ label: "Insert event below\u2026", click: () => send("insertBelow") },
|
|
1889
|
+
{ type: "separator" },
|
|
1890
|
+
{
|
|
1891
|
+
label: `Branch from here (steps 1\u2026${eventIdx + 1} of ${total})\u2026`,
|
|
1892
|
+
click: () => send("branch")
|
|
1893
|
+
}
|
|
1894
|
+
]);
|
|
1895
|
+
menu.popup({ window: s.win });
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
function registerIpc(state) {
|
|
1900
|
+
const h = handlers(state);
|
|
1901
|
+
for (const channel of Object.keys(h)) {
|
|
1902
|
+
import_electron.ipcMain.handle(channel, (_evt, args) => h[channel](state, args));
|
|
1903
|
+
}
|
|
1904
|
+
broadcastStatus(state);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/main/main.ts
|
|
1908
|
+
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "true";
|
|
1909
|
+
var SESSIONS_DIR = process.env.TRQ_SESSIONS_DIR ? import_node_path7.default.resolve(process.env.TRQ_SESSIONS_DIR) : import_node_path7.default.join(process.cwd(), "sessions");
|
|
1910
|
+
var PRELOAD = import_node_path7.default.join(__dirname, "../preload/preload.cjs");
|
|
1911
|
+
var HEADER_H = 32 + 32;
|
|
1912
|
+
var PAUSE_BANNER_H = 36;
|
|
1913
|
+
var STATUS_BAR_H = 22;
|
|
1914
|
+
var PAGE_PADDING = 8;
|
|
1915
|
+
var SIDEBAR_W_DEFAULT = 48 + 240;
|
|
1916
|
+
var EVENTS_H_DEFAULT = 180;
|
|
1917
|
+
var INSPECTOR_W = 280;
|
|
1918
|
+
var INSPECTOR_W_COLLAPSED = 28;
|
|
1919
|
+
async function createWindow() {
|
|
1920
|
+
const win = new import_electron2.BrowserWindow({
|
|
1921
|
+
width: 1280,
|
|
1922
|
+
height: 820,
|
|
1923
|
+
backgroundColor: "#0a0a0a",
|
|
1924
|
+
webPreferences: {
|
|
1925
|
+
preload: PRELOAD,
|
|
1926
|
+
contextIsolation: true,
|
|
1927
|
+
nodeIntegration: false
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
const view = new import_electron2.BrowserView({
|
|
1931
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false }
|
|
1932
|
+
});
|
|
1933
|
+
win.setBrowserView(view);
|
|
1934
|
+
const primaryTab = {
|
|
1935
|
+
id: "tab_0",
|
|
1936
|
+
view,
|
|
1937
|
+
url: "about:blank",
|
|
1938
|
+
title: "",
|
|
1939
|
+
client: null
|
|
1940
|
+
};
|
|
1941
|
+
const state = {
|
|
1942
|
+
sessionsDir: SESSIONS_DIR,
|
|
1943
|
+
win,
|
|
1944
|
+
layout: { sidebarW: SIDEBAR_W_DEFAULT, eventsH: EVENTS_H_DEFAULT },
|
|
1945
|
+
view,
|
|
1946
|
+
tabs: [primaryTab],
|
|
1947
|
+
activeTabId: primaryTab.id,
|
|
1948
|
+
mode: "idle",
|
|
1949
|
+
session: null,
|
|
1950
|
+
recordingClient: null,
|
|
1951
|
+
recordingContext: null,
|
|
1952
|
+
cancelReplay: false,
|
|
1953
|
+
inspectActive: false,
|
|
1954
|
+
pauseFailure: null,
|
|
1955
|
+
pauseResolver: null,
|
|
1956
|
+
inspectorOpen: false,
|
|
1957
|
+
inspectorCollapsed: false,
|
|
1958
|
+
inspectAssets: null,
|
|
1959
|
+
stepResolver: null,
|
|
1960
|
+
runToEnd: false,
|
|
1961
|
+
replayCursor: null,
|
|
1962
|
+
inspectLoopPromise: null,
|
|
1963
|
+
lockedViewport: null,
|
|
1964
|
+
// Replaced below after sizeView is defined.
|
|
1965
|
+
invalidateLayout: () => {
|
|
1966
|
+
}
|
|
1967
|
+
};
|
|
1968
|
+
let inSizeView = false;
|
|
1969
|
+
const sizeView = () => {
|
|
1970
|
+
if (inSizeView) return;
|
|
1971
|
+
inSizeView = true;
|
|
1972
|
+
try {
|
|
1973
|
+
const active = state.view;
|
|
1974
|
+
const [w, h] = win.getContentSize();
|
|
1975
|
+
const rightReserve = state.inspectorOpen ? state.inspectorCollapsed ? INSPECTOR_W_COLLAPSED : INSPECTOR_W : 0;
|
|
1976
|
+
const left = state.layout.sidebarW;
|
|
1977
|
+
const eventsH = state.layout.eventsH;
|
|
1978
|
+
const bannerH = state.pauseFailure ? PAUSE_BANNER_H : 0;
|
|
1979
|
+
const bounds = {
|
|
1980
|
+
x: left + PAGE_PADDING,
|
|
1981
|
+
y: HEADER_H + bannerH + PAGE_PADDING,
|
|
1982
|
+
width: Math.max(0, w - left - rightReserve - 2 * PAGE_PADDING),
|
|
1983
|
+
height: Math.max(0, h - HEADER_H - bannerH - eventsH - STATUS_BAR_H - 2 * PAGE_PADDING)
|
|
1984
|
+
};
|
|
1985
|
+
active.setBounds(bounds);
|
|
1986
|
+
if (process.env.TRQ_DEBUG_LAYOUT) {
|
|
1987
|
+
console.log(`[layout] window=${w}x${h} inspectorOpen=${state.inspectorOpen} view=${JSON.stringify(bounds)}`);
|
|
1988
|
+
}
|
|
1989
|
+
if (process.platform === "darwin") {
|
|
1990
|
+
win.setContentSize(w, h + 1);
|
|
1991
|
+
win.setContentSize(w, h);
|
|
1992
|
+
}
|
|
1993
|
+
if (state.recordingClient && bounds.width > 0 && bounds.height > 0) {
|
|
1994
|
+
const lock = state.lockedViewport;
|
|
1995
|
+
const params = lock ? {
|
|
1996
|
+
width: lock.width,
|
|
1997
|
+
height: lock.height,
|
|
1998
|
+
// Use min(ratio_w, ratio_h) so neither dimension overflows.
|
|
1999
|
+
// Capped at 1 so a small viewport doesn't get upscaled.
|
|
2000
|
+
scale: Math.min(1, bounds.width / lock.width, bounds.height / lock.height),
|
|
2001
|
+
deviceScaleFactor: 1,
|
|
2002
|
+
mobile: false,
|
|
2003
|
+
dontSetVisibleSize: true
|
|
2004
|
+
} : {
|
|
2005
|
+
width: bounds.width,
|
|
2006
|
+
height: bounds.height,
|
|
2007
|
+
deviceScaleFactor: 1,
|
|
2008
|
+
mobile: false,
|
|
2009
|
+
dontSetVisibleSize: true
|
|
2010
|
+
};
|
|
2011
|
+
if (process.env.TRQ_DEBUG_LAYOUT) {
|
|
2012
|
+
console.log(`[layout] setDeviceMetricsOverride ${JSON.stringify(params)}`);
|
|
2013
|
+
}
|
|
2014
|
+
state.recordingClient.send("Emulation.setDeviceMetricsOverride", params).catch((err) => {
|
|
2015
|
+
if (process.env.TRQ_DEBUG_LAYOUT) {
|
|
2016
|
+
console.log(`[layout] FAILED: ${err.message}`);
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
} else if (process.env.TRQ_DEBUG_LAYOUT) {
|
|
2020
|
+
console.log(`[layout] skipped: client=${!!state.recordingClient}`);
|
|
2021
|
+
}
|
|
2022
|
+
} finally {
|
|
2023
|
+
inSizeView = false;
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
state.invalidateLayout = sizeView;
|
|
2027
|
+
sizeView();
|
|
2028
|
+
win.setMaxListeners(20);
|
|
2029
|
+
win.on("resize", sizeView);
|
|
2030
|
+
wireUpTab(state, primaryTab);
|
|
2031
|
+
registerIpc(state);
|
|
2032
|
+
win.webContents.on("console-message", (_e, level, message, line, source) => {
|
|
2033
|
+
const tag = ["log", "warn", "error"][level] ?? "log";
|
|
2034
|
+
console.log(`[renderer:${tag}] ${message} (${source}:${line})`);
|
|
2035
|
+
});
|
|
2036
|
+
win.webContents.on("preload-error", (_e, file, err) => {
|
|
2037
|
+
console.error(`[preload-error] ${file}:`, err);
|
|
2038
|
+
});
|
|
2039
|
+
await win.loadFile(import_node_path7.default.join(__dirname, "../renderer/index.html"));
|
|
2040
|
+
await view.webContents.loadURL("about:blank");
|
|
2041
|
+
if (process.env.TRQ_DEVTOOLS) {
|
|
2042
|
+
win.webContents.openDevTools({ mode: "detach" });
|
|
2043
|
+
}
|
|
2044
|
+
const autoTest = process.env.TRQ_AUTO_TEST;
|
|
2045
|
+
if (autoTest === "1") {
|
|
2046
|
+
runAutoTest(win, view).catch((err) => {
|
|
2047
|
+
console.error("[auto-test] failed:", err);
|
|
2048
|
+
import_electron2.app.exit(1);
|
|
2049
|
+
});
|
|
2050
|
+
} else if (autoTest === "config") {
|
|
2051
|
+
runConfigAutoTest(win, view).catch((err) => {
|
|
2052
|
+
console.error("[config-test] failed:", err);
|
|
2053
|
+
import_electron2.app.exit(1);
|
|
2054
|
+
});
|
|
2055
|
+
} else if (autoTest === "play") {
|
|
2056
|
+
runPlayAutoTest(win, view).catch((err) => {
|
|
2057
|
+
console.error("[play-test] failed:", err);
|
|
2058
|
+
import_electron2.app.exit(1);
|
|
2059
|
+
});
|
|
2060
|
+
} else if (autoTest === "inspect") {
|
|
2061
|
+
runInspectAutoTest(win, view).catch((err) => {
|
|
2062
|
+
console.error("[inspect-test] failed:", err);
|
|
2063
|
+
import_electron2.app.exit(1);
|
|
2064
|
+
});
|
|
2065
|
+
} else if (autoTest === "debug") {
|
|
2066
|
+
runDebugAutoTest(win, view).catch((err) => {
|
|
2067
|
+
console.error("[debug-test] failed:", err);
|
|
2068
|
+
import_electron2.app.exit(1);
|
|
2069
|
+
});
|
|
2070
|
+
} else if (autoTest === "responsive") {
|
|
2071
|
+
runResponsiveAutoTest(win, view).catch((err) => {
|
|
2072
|
+
console.error("[responsive-test] failed:", err);
|
|
2073
|
+
import_electron2.app.exit(1);
|
|
2074
|
+
});
|
|
2075
|
+
} else if (autoTest === "tabs") {
|
|
2076
|
+
runTabsAutoTest(win, state).catch((err) => {
|
|
2077
|
+
console.error("[tabs-test] failed:", err);
|
|
2078
|
+
import_electron2.app.exit(1);
|
|
2079
|
+
});
|
|
2080
|
+
} else if (autoTest === "scaledViewport") {
|
|
2081
|
+
runScaledViewportAutoTest(win, state).catch((err) => {
|
|
2082
|
+
console.error("[scaledviewport-test] failed:", err);
|
|
2083
|
+
import_electron2.app.exit(1);
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
const cleanup = () => {
|
|
2087
|
+
try {
|
|
2088
|
+
const dbg = view.webContents.debugger;
|
|
2089
|
+
if (dbg.isAttached()) dbg.detach();
|
|
2090
|
+
} catch {
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
win.on("closed", cleanup);
|
|
2094
|
+
import_electron2.app.on("before-quit", cleanup);
|
|
2095
|
+
}
|
|
2096
|
+
async function runAutoTest(win, view) {
|
|
2097
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? `gui-smoketest-${Date.now()}`;
|
|
2098
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2099
|
+
console.log(`[auto-test] creating session ${name}`);
|
|
2100
|
+
await win.webContents.executeJavaScript(
|
|
2101
|
+
`window.trq.invoke('sessions:create', { name: ${JSON.stringify(name)}, startUrl: 'https://example.com' })`
|
|
2102
|
+
);
|
|
2103
|
+
console.log("[auto-test] starting recording");
|
|
2104
|
+
await win.webContents.executeJavaScript(
|
|
2105
|
+
`window.trq.invoke('recording:start', { name: ${JSON.stringify(name)} })`
|
|
2106
|
+
);
|
|
2107
|
+
await wait(500);
|
|
2108
|
+
console.log("[auto-test] navigating BrowserView to example.com");
|
|
2109
|
+
await win.webContents.executeJavaScript(
|
|
2110
|
+
`window.trq.invoke('view:navigate', { url: 'https://example.com' })`
|
|
2111
|
+
);
|
|
2112
|
+
await wait(3e3);
|
|
2113
|
+
console.log("[auto-test] locating link to click");
|
|
2114
|
+
const linkBox = await view.webContents.executeJavaScript(`
|
|
2115
|
+
(() => {
|
|
2116
|
+
const links = Array.from(document.querySelectorAll('a'));
|
|
2117
|
+
console.log('[page] found', links.length, 'links:', links.map((a) => a.textContent));
|
|
2118
|
+
const a = links[0];
|
|
2119
|
+
if (!a) return null;
|
|
2120
|
+
const r = a.getBoundingClientRect();
|
|
2121
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), text: a.textContent };
|
|
2122
|
+
})()
|
|
2123
|
+
`);
|
|
2124
|
+
console.log("[auto-test] linkBox:", linkBox);
|
|
2125
|
+
if (!linkBox) {
|
|
2126
|
+
console.log("[auto-test] WARN: link not found, skipping click");
|
|
2127
|
+
} else {
|
|
2128
|
+
await view.webContents.sendInputEvent({ type: "mouseMove", x: linkBox.x, y: linkBox.y });
|
|
2129
|
+
await view.webContents.sendInputEvent({ type: "mouseDown", x: linkBox.x, y: linkBox.y, button: "left", clickCount: 1 });
|
|
2130
|
+
await view.webContents.sendInputEvent({ type: "mouseUp", x: linkBox.x, y: linkBox.y, button: "left", clickCount: 1 });
|
|
2131
|
+
}
|
|
2132
|
+
await wait(2500);
|
|
2133
|
+
console.log("[auto-test] stopping recording");
|
|
2134
|
+
await win.webContents.executeJavaScript(
|
|
2135
|
+
`window.trq.invoke('recording:stop', undefined)`
|
|
2136
|
+
);
|
|
2137
|
+
await wait(500);
|
|
2138
|
+
const events = await win.webContents.executeJavaScript(
|
|
2139
|
+
`window.trq.invoke('sessions:events', { name: ${JSON.stringify(name)} })`
|
|
2140
|
+
);
|
|
2141
|
+
console.log(`[auto-test] captured ${events.length} events:`);
|
|
2142
|
+
for (const e of events) console.log(` ${e.id} ${e.type}`);
|
|
2143
|
+
console.log("[auto-test] PASS \u2014 quitting");
|
|
2144
|
+
import_electron2.app.quit();
|
|
2145
|
+
}
|
|
2146
|
+
async function runConfigAutoTest(win, view) {
|
|
2147
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? `cfg-smoketest-${Date.now()}`;
|
|
2148
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2149
|
+
const CONFIG = {
|
|
2150
|
+
viewport: { width: 800, height: 600, deviceScaleFactor: 1, mobile: false },
|
|
2151
|
+
permissions: ["geolocation"],
|
|
2152
|
+
geolocation: { latitude: 52.52, longitude: 13.405, accuracy: 5 },
|
|
2153
|
+
locale: "de-DE",
|
|
2154
|
+
timezone: "Europe/Berlin"
|
|
2155
|
+
};
|
|
2156
|
+
console.log(`[config-test] creating session ${name}`);
|
|
2157
|
+
await win.webContents.executeJavaScript(
|
|
2158
|
+
`window.trq.invoke('sessions:create', { name: ${JSON.stringify(name)}, startUrl: 'about:blank' })`
|
|
2159
|
+
);
|
|
2160
|
+
console.log("[config-test] writing config:", CONFIG);
|
|
2161
|
+
await win.webContents.executeJavaScript(
|
|
2162
|
+
`window.trq.invoke('config:set', { name: ${JSON.stringify(name)}, config: ${JSON.stringify(CONFIG)} })`
|
|
2163
|
+
);
|
|
2164
|
+
console.log("[config-test] starting recording");
|
|
2165
|
+
await win.webContents.executeJavaScript(
|
|
2166
|
+
`window.trq.invoke('recording:start', { name: ${JSON.stringify(name)} })`
|
|
2167
|
+
);
|
|
2168
|
+
await wait(600);
|
|
2169
|
+
console.log("[config-test] navigating to https://example.com");
|
|
2170
|
+
await win.webContents.executeJavaScript(
|
|
2171
|
+
`window.trq.invoke('view:navigate', { url: 'https://example.com' })`
|
|
2172
|
+
);
|
|
2173
|
+
await wait(2500);
|
|
2174
|
+
const probe = await view.webContents.executeJavaScript(`
|
|
2175
|
+
(async () => {
|
|
2176
|
+
const out = {
|
|
2177
|
+
innerWidth: window.innerWidth,
|
|
2178
|
+
innerHeight: window.innerHeight,
|
|
2179
|
+
locale: (new Intl.DateTimeFormat()).resolvedOptions().locale,
|
|
2180
|
+
timezone: (new Intl.DateTimeFormat()).resolvedOptions().timeZone,
|
|
2181
|
+
geo: null,
|
|
2182
|
+
geoErr: null,
|
|
2183
|
+
};
|
|
2184
|
+
try {
|
|
2185
|
+
out.geo = await new Promise((resolve, reject) => {
|
|
2186
|
+
navigator.geolocation.getCurrentPosition(
|
|
2187
|
+
(pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude, acc: pos.coords.accuracy }),
|
|
2188
|
+
(err) => reject(err.message || String(err)),
|
|
2189
|
+
{ timeout: 4000 },
|
|
2190
|
+
);
|
|
2191
|
+
});
|
|
2192
|
+
} catch (e) { out.geoErr = String(e); }
|
|
2193
|
+
return out;
|
|
2194
|
+
})()
|
|
2195
|
+
`);
|
|
2196
|
+
console.log("[config-test] probe result:", JSON.stringify(probe, null, 2));
|
|
2197
|
+
const checks = [
|
|
2198
|
+
["viewport.width", probe.innerWidth, CONFIG.viewport.width],
|
|
2199
|
+
["viewport.height", probe.innerHeight, CONFIG.viewport.height],
|
|
2200
|
+
["locale", probe.locale, CONFIG.locale],
|
|
2201
|
+
["timezone", probe.timezone, CONFIG.timezone],
|
|
2202
|
+
["geo.lat", probe.geo?.lat, CONFIG.geolocation.latitude],
|
|
2203
|
+
["geo.lng", probe.geo?.lng, CONFIG.geolocation.longitude]
|
|
2204
|
+
];
|
|
2205
|
+
let pass = true;
|
|
2206
|
+
for (const [field, actual, expected] of checks) {
|
|
2207
|
+
const ok = actual === expected;
|
|
2208
|
+
console.log(` [${ok ? "\u2713" : "\u2717"}] ${field}: ${JSON.stringify(actual)} (expected ${JSON.stringify(expected)})`);
|
|
2209
|
+
if (!ok) pass = false;
|
|
2210
|
+
}
|
|
2211
|
+
console.log("[config-test] stopping recording");
|
|
2212
|
+
await win.webContents.executeJavaScript(
|
|
2213
|
+
`window.trq.invoke('recording:stop', undefined)`
|
|
2214
|
+
);
|
|
2215
|
+
await wait(400);
|
|
2216
|
+
console.log(`[config-test] ${pass ? "PASS" : "FAIL"}`);
|
|
2217
|
+
import_electron2.app.exit(pass ? 0 : 1);
|
|
2218
|
+
}
|
|
2219
|
+
async function runPlayAutoTest(win, view) {
|
|
2220
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? "gui-smoketest";
|
|
2221
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2222
|
+
const timeoutMs = +(process.env.TRQ_AUTO_TEST_TIMEOUT ?? "120000");
|
|
2223
|
+
await win.webContents.executeJavaScript(`
|
|
2224
|
+
(() => {
|
|
2225
|
+
window.trq.on('session:status', (s) => {
|
|
2226
|
+
console.log('[status] mode=' + s.mode);
|
|
2227
|
+
});
|
|
2228
|
+
window.trq.on('replay:progress', (p) => {
|
|
2229
|
+
console.log('[progress] ' + p.current + '/' + p.total + ' ' + p.eventType + ' (' + p.status + (p.detail ? ' \u2014 ' + p.detail : '') + ')');
|
|
2230
|
+
});
|
|
2231
|
+
return null;
|
|
2232
|
+
})()
|
|
2233
|
+
`);
|
|
2234
|
+
const runOne = async (label) => {
|
|
2235
|
+
console.log(`[${label}] invoke replay:play`);
|
|
2236
|
+
const invokePromise = win.webContents.executeJavaScript(
|
|
2237
|
+
`window.trq.invoke('replay:play', { name: ${JSON.stringify(name)} }).then(() => '__OK__').catch((e) => '__ERR__: ' + e.message)`
|
|
2238
|
+
);
|
|
2239
|
+
const result = await Promise.race([
|
|
2240
|
+
invokePromise,
|
|
2241
|
+
new Promise((resolve) => setTimeout(() => resolve("__TIMEOUT__"), timeoutMs))
|
|
2242
|
+
]);
|
|
2243
|
+
console.log(`[${label}] result: ${result} / final URL: ${view.webContents.getURL()}`);
|
|
2244
|
+
return result;
|
|
2245
|
+
};
|
|
2246
|
+
const a = await runOne("play-1");
|
|
2247
|
+
if (a !== "__OK__") {
|
|
2248
|
+
import_electron2.app.exit(1);
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
console.log("[play-test] waiting 2s before play-2\u2026");
|
|
2252
|
+
await wait(2e3);
|
|
2253
|
+
const b = await runOne("play-2");
|
|
2254
|
+
import_electron2.app.exit(a === "__OK__" && b === "__OK__" ? 0 : 1);
|
|
2255
|
+
}
|
|
2256
|
+
async function runInspectAutoTest(win, view) {
|
|
2257
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2258
|
+
console.log("[inspect-test] starting inspect mode");
|
|
2259
|
+
await win.webContents.executeJavaScript(
|
|
2260
|
+
`window.trq.invoke('inspect:start', undefined)`
|
|
2261
|
+
);
|
|
2262
|
+
await wait(500);
|
|
2263
|
+
console.log("[inspect-test] navigating BrowserView to example.com");
|
|
2264
|
+
await win.webContents.executeJavaScript(
|
|
2265
|
+
`window.trq.invoke('view:navigate', { url: 'https://example.com' })`
|
|
2266
|
+
);
|
|
2267
|
+
await wait(3e3);
|
|
2268
|
+
let hoverCount = 0;
|
|
2269
|
+
await win.webContents.executeJavaScript(`
|
|
2270
|
+
(() => {
|
|
2271
|
+
window.__inspectHovers = [];
|
|
2272
|
+
window.trq.on('inspect:hover', (h) => {
|
|
2273
|
+
window.__inspectHovers.push(h);
|
|
2274
|
+
console.log('[hover] ' + JSON.stringify({
|
|
2275
|
+
tag: h.target.tagName,
|
|
2276
|
+
firstSelector: h.selectors && h.selectors[0] && h.selectors[0][0],
|
|
2277
|
+
}));
|
|
2278
|
+
});
|
|
2279
|
+
return null;
|
|
2280
|
+
})()
|
|
2281
|
+
`);
|
|
2282
|
+
const linkBox = await view.webContents.executeJavaScript(`
|
|
2283
|
+
(() => {
|
|
2284
|
+
const a = document.querySelector('a');
|
|
2285
|
+
if (!a) return null;
|
|
2286
|
+
const r = a.getBoundingClientRect();
|
|
2287
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), text: a.textContent };
|
|
2288
|
+
})()
|
|
2289
|
+
`);
|
|
2290
|
+
console.log("[inspect-test] target link:", linkBox);
|
|
2291
|
+
if (!linkBox) {
|
|
2292
|
+
import_electron2.app.exit(1);
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
await view.webContents.sendInputEvent({ type: "mouseMove", x: 20, y: 20 });
|
|
2296
|
+
await wait(80);
|
|
2297
|
+
await view.webContents.sendInputEvent({ type: "mouseMove", x: linkBox.x, y: linkBox.y });
|
|
2298
|
+
await wait(800);
|
|
2299
|
+
const hovers = await win.webContents.executeJavaScript("window.__inspectHovers");
|
|
2300
|
+
console.log(`[inspect-test] received ${hovers.length} hover events`);
|
|
2301
|
+
if (hovers.length > 0) {
|
|
2302
|
+
const last = hovers[hovers.length - 1];
|
|
2303
|
+
console.log("[inspect-test] last hover selectors:");
|
|
2304
|
+
for (const s of last.selectors) console.log(" - " + s[0]);
|
|
2305
|
+
}
|
|
2306
|
+
hoverCount = hovers.length;
|
|
2307
|
+
if (hovers.length > 0) {
|
|
2308
|
+
const sels = hovers[hovers.length - 1].selectors;
|
|
2309
|
+
const ok = await win.webContents.executeJavaScript(
|
|
2310
|
+
`window.trq.invoke('inspect:highlight', { selectors: ${JSON.stringify(sels)} })`
|
|
2311
|
+
);
|
|
2312
|
+
console.log("[inspect-test] highlight returned:", ok);
|
|
2313
|
+
await wait(1e3);
|
|
2314
|
+
}
|
|
2315
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:stop', undefined)`);
|
|
2316
|
+
console.log(`[inspect-test] ${hoverCount > 0 ? "PASS" : "FAIL"}`);
|
|
2317
|
+
import_electron2.app.exit(hoverCount > 0 ? 0 : 1);
|
|
2318
|
+
}
|
|
2319
|
+
async function runDebugAutoTest(win, view) {
|
|
2320
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? "assert-test";
|
|
2321
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2322
|
+
await win.webContents.executeJavaScript(`
|
|
2323
|
+
(() => {
|
|
2324
|
+
window.__cursors = [];
|
|
2325
|
+
window.__statuses = [];
|
|
2326
|
+
window.trq.on('replay:cursor', (c) => {
|
|
2327
|
+
window.__cursors.push(c);
|
|
2328
|
+
console.log('[cursor] ' + c.idx + '/' + c.total + ' ' + (c.eventId ?? '-') + ' ' + (c.eventType ?? '-'));
|
|
2329
|
+
});
|
|
2330
|
+
window.trq.on('session:status', (s) => {
|
|
2331
|
+
window.__statuses.push(s);
|
|
2332
|
+
console.log('[status] mode=' + s.mode);
|
|
2333
|
+
});
|
|
2334
|
+
return null;
|
|
2335
|
+
})()
|
|
2336
|
+
`);
|
|
2337
|
+
console.log(`[debug-test] starting inspect on session: ${name}`);
|
|
2338
|
+
await win.webContents.executeJavaScript(
|
|
2339
|
+
`window.trq.invoke('inspect:start', { name: ${JSON.stringify(name)} })`
|
|
2340
|
+
);
|
|
2341
|
+
let cursors = [];
|
|
2342
|
+
for (let i = 0; i < 30; i++) {
|
|
2343
|
+
cursors = await win.webContents.executeJavaScript("window.__cursors");
|
|
2344
|
+
if (cursors.length > 0) break;
|
|
2345
|
+
await wait(200);
|
|
2346
|
+
}
|
|
2347
|
+
if (cursors.length === 0) {
|
|
2348
|
+
console.log("[debug-test] FAIL \u2014 no cursor received after inspect:start");
|
|
2349
|
+
import_electron2.app.exit(1);
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
const first = cursors[0];
|
|
2353
|
+
console.log(`[debug-test] first cursor: idx=${first.idx} total=${first.total} eventType=${first.eventType}`);
|
|
2354
|
+
console.log("[debug-test] step-over");
|
|
2355
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:step', undefined)`);
|
|
2356
|
+
await wait(2500);
|
|
2357
|
+
cursors = await win.webContents.executeJavaScript("window.__cursors");
|
|
2358
|
+
console.log(`[debug-test] cursors so far: ${cursors.length}`);
|
|
2359
|
+
if (cursors.length < 2) {
|
|
2360
|
+
console.log("[debug-test] FAIL \u2014 no second cursor after step");
|
|
2361
|
+
import_electron2.app.exit(1);
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
console.log(`[debug-test] second cursor: idx=${cursors[1].idx} eventType=${cursors[1].eventType}`);
|
|
2365
|
+
console.log("[debug-test] run-to-end");
|
|
2366
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:continue', undefined)`);
|
|
2367
|
+
let modeIdle = false;
|
|
2368
|
+
for (let i = 0; i < 60; i++) {
|
|
2369
|
+
const statuses = await win.webContents.executeJavaScript("window.__statuses");
|
|
2370
|
+
if (statuses.find((s) => s.mode === "idle" && statuses.indexOf(s) > 0)) {
|
|
2371
|
+
modeIdle = true;
|
|
2372
|
+
break;
|
|
2373
|
+
}
|
|
2374
|
+
await wait(500);
|
|
2375
|
+
}
|
|
2376
|
+
console.log(`[debug-test] mode returned to idle: ${modeIdle}`);
|
|
2377
|
+
console.log(`[debug-test] ${modeIdle ? "PASS" : "FAIL"}`);
|
|
2378
|
+
import_electron2.app.exit(modeIdle ? 0 : 1);
|
|
2379
|
+
}
|
|
2380
|
+
async function runResponsiveAutoTest(win, view) {
|
|
2381
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? "assert-test";
|
|
2382
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2383
|
+
console.log(`[responsive-test] starting inspect on session: ${name}`);
|
|
2384
|
+
await win.webContents.executeJavaScript(
|
|
2385
|
+
`window.trq.invoke('inspect:start', { name: ${JSON.stringify(name)} })`
|
|
2386
|
+
);
|
|
2387
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:step', undefined)`);
|
|
2388
|
+
await wait(2500);
|
|
2389
|
+
const expanded = await view.webContents.executeJavaScript(`
|
|
2390
|
+
({ innerWidth: window.innerWidth, innerHeight: window.innerHeight })
|
|
2391
|
+
`);
|
|
2392
|
+
console.log(`[responsive-test] expanded inspector \u2192 page reports ${expanded.innerWidth}x${expanded.innerHeight}`);
|
|
2393
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:toggleCollapsed', undefined)`);
|
|
2394
|
+
await wait(500);
|
|
2395
|
+
const collapsed = await view.webContents.executeJavaScript(`
|
|
2396
|
+
({ innerWidth: window.innerWidth, innerHeight: window.innerHeight })
|
|
2397
|
+
`);
|
|
2398
|
+
console.log(`[responsive-test] collapsed inspector \u2192 page reports ${collapsed.innerWidth}x${collapsed.innerHeight}`);
|
|
2399
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('inspect:toggleCollapsed', undefined)`);
|
|
2400
|
+
await wait(500);
|
|
2401
|
+
const reExpanded = await view.webContents.executeJavaScript(`
|
|
2402
|
+
({ innerWidth: window.innerWidth, innerHeight: window.innerHeight })
|
|
2403
|
+
`);
|
|
2404
|
+
console.log(`[responsive-test] re-expanded \u2192 page reports ${reExpanded.innerWidth}x${reExpanded.innerHeight}`);
|
|
2405
|
+
const tolerance = 4;
|
|
2406
|
+
const expectedExpanded = 1280 - SIDEBAR_W - INSPECTOR_W;
|
|
2407
|
+
const expectedCollapsed = 1280 - SIDEBAR_W - INSPECTOR_W_COLLAPSED;
|
|
2408
|
+
const pass = Math.abs(expanded.innerWidth - expectedExpanded) < tolerance && Math.abs(collapsed.innerWidth - expectedCollapsed) < tolerance && Math.abs(reExpanded.innerWidth - expectedExpanded) < tolerance;
|
|
2409
|
+
console.log(`[responsive-test] expected expanded=${expectedExpanded}, collapsed=${expectedCollapsed}`);
|
|
2410
|
+
console.log(`[responsive-test] ${pass ? "PASS" : "FAIL"}`);
|
|
2411
|
+
import_electron2.app.exit(pass ? 0 : 1);
|
|
2412
|
+
}
|
|
2413
|
+
async function runTabsAutoTest(win, state) {
|
|
2414
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? `tabs-smoketest-${Date.now()}`;
|
|
2415
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2416
|
+
console.log(`[tabs-test] creating session ${name}`);
|
|
2417
|
+
await win.webContents.executeJavaScript(
|
|
2418
|
+
`window.trq.invoke('sessions:create', { name: ${JSON.stringify(name)}, startUrl: 'about:blank' })`
|
|
2419
|
+
);
|
|
2420
|
+
console.log("[tabs-test] starting recording");
|
|
2421
|
+
await win.webContents.executeJavaScript(
|
|
2422
|
+
`window.trq.invoke('recording:start', { name: ${JSON.stringify(name)} })`
|
|
2423
|
+
);
|
|
2424
|
+
await wait(600);
|
|
2425
|
+
const dataUrl = "data:text/html," + encodeURIComponent(
|
|
2426
|
+
'<!doctype html><meta charset=utf8><title>Tab Test</title><body style="font: 16px sans-serif; padding: 20px"><a id="link" href="https://example.com" target="_blank">open in new tab</a></body>'
|
|
2427
|
+
);
|
|
2428
|
+
console.log("[tabs-test] navigating active tab to data: URL");
|
|
2429
|
+
await win.webContents.executeJavaScript(
|
|
2430
|
+
`window.trq.invoke('view:navigate', { url: ${JSON.stringify(dataUrl)} })`
|
|
2431
|
+
);
|
|
2432
|
+
await wait(1500);
|
|
2433
|
+
const linkBox = await state.view.webContents.executeJavaScript(`
|
|
2434
|
+
(() => {
|
|
2435
|
+
const a = document.getElementById('link');
|
|
2436
|
+
if (!a) return null;
|
|
2437
|
+
const r = a.getBoundingClientRect();
|
|
2438
|
+
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
|
|
2439
|
+
})()
|
|
2440
|
+
`);
|
|
2441
|
+
if (!linkBox) {
|
|
2442
|
+
console.log("[tabs-test] FAIL \u2014 link not found");
|
|
2443
|
+
import_electron2.app.exit(1);
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
console.log(`[tabs-test] clicking link at ${linkBox.x},${linkBox.y}`);
|
|
2447
|
+
await state.view.webContents.sendInputEvent({ type: "mouseMove", x: linkBox.x, y: linkBox.y });
|
|
2448
|
+
await state.view.webContents.sendInputEvent({ type: "mouseDown", x: linkBox.x, y: linkBox.y, button: "left", clickCount: 1 });
|
|
2449
|
+
await state.view.webContents.sendInputEvent({ type: "mouseUp", x: linkBox.x, y: linkBox.y, button: "left", clickCount: 1 });
|
|
2450
|
+
await wait(3e3);
|
|
2451
|
+
console.log(`[tabs-test] tabs.length = ${state.tabs.length}`);
|
|
2452
|
+
console.log(`[tabs-test] active tab url = ${state.view.webContents.getURL()}`);
|
|
2453
|
+
const sawNewTab = state.tabs.length === 2;
|
|
2454
|
+
const newTabUrl = state.tabs[1]?.view.webContents.getURL() ?? "";
|
|
2455
|
+
const newTabLoadedExample = newTabUrl.includes("example.com");
|
|
2456
|
+
console.log(`[tabs-test] sawNewTab=${sawNewTab} newTabLoadedExample=${newTabLoadedExample}`);
|
|
2457
|
+
console.log("[tabs-test] stopping recording");
|
|
2458
|
+
await win.webContents.executeJavaScript(
|
|
2459
|
+
`window.trq.invoke('recording:stop', undefined)`
|
|
2460
|
+
);
|
|
2461
|
+
await wait(500);
|
|
2462
|
+
const pass = sawNewTab && newTabLoadedExample;
|
|
2463
|
+
console.log(`[tabs-test] ${pass ? "PASS" : "FAIL"}`);
|
|
2464
|
+
import_electron2.app.exit(pass ? 0 : 1);
|
|
2465
|
+
}
|
|
2466
|
+
async function runScaledViewportAutoTest(win, state) {
|
|
2467
|
+
const name = process.env.TRQ_AUTO_TEST_SESSION ?? `scaledvp-smoketest-${Date.now()}`;
|
|
2468
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2469
|
+
console.log(`[scaledviewport-test] creating session ${name}`);
|
|
2470
|
+
await win.webContents.executeJavaScript(
|
|
2471
|
+
`window.trq.invoke('sessions:create', { name: ${JSON.stringify(name)}, startUrl: 'about:blank' })`
|
|
2472
|
+
);
|
|
2473
|
+
const LOCKED = { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false };
|
|
2474
|
+
console.log(`[scaledviewport-test] writing config.viewport = ${LOCKED.width}x${LOCKED.height}`);
|
|
2475
|
+
await win.webContents.executeJavaScript(
|
|
2476
|
+
`window.trq.invoke('config:set', { name: ${JSON.stringify(name)}, config: { viewport: ${JSON.stringify(LOCKED)} } })`
|
|
2477
|
+
);
|
|
2478
|
+
console.log("[scaledviewport-test] starting recording");
|
|
2479
|
+
await win.webContents.executeJavaScript(
|
|
2480
|
+
`window.trq.invoke('recording:start', { name: ${JSON.stringify(name)} })`
|
|
2481
|
+
);
|
|
2482
|
+
await wait(800);
|
|
2483
|
+
const dataUrl = "data:text/html," + encodeURIComponent(
|
|
2484
|
+
'<!doctype html><meta charset=utf8><title>Scaled VP Test</title><style>body{margin:0} #m{width:1920px;height:1080px;background:linear-gradient(90deg,#f00,#0f0,#00f)}</style><body><div id="m">marker</div></body>'
|
|
2485
|
+
);
|
|
2486
|
+
await win.webContents.executeJavaScript(
|
|
2487
|
+
`window.trq.invoke('view:navigate', { url: ${JSON.stringify(dataUrl)} })`
|
|
2488
|
+
);
|
|
2489
|
+
await wait(1500);
|
|
2490
|
+
const viewBounds = state.view.getBounds();
|
|
2491
|
+
console.log(`[scaledviewport-test] BrowserView bounds: ${viewBounds.width}x${viewBounds.height}`);
|
|
2492
|
+
const probe = await state.view.webContents.executeJavaScript(`
|
|
2493
|
+
({
|
|
2494
|
+
innerWidth: window.innerWidth,
|
|
2495
|
+
innerHeight: window.innerHeight,
|
|
2496
|
+
scrollWidth: document.documentElement.scrollWidth,
|
|
2497
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
2498
|
+
})
|
|
2499
|
+
`);
|
|
2500
|
+
console.log(`[scaledviewport-test] page reports: ${JSON.stringify(probe)}`);
|
|
2501
|
+
await win.webContents.executeJavaScript(`window.trq.invoke('recording:stop', undefined)`);
|
|
2502
|
+
await wait(400);
|
|
2503
|
+
const expectedScale = Math.min(1, viewBounds.width / LOCKED.width, viewBounds.height / LOCKED.height);
|
|
2504
|
+
const checks = [
|
|
2505
|
+
["innerWidth (page should see locked width)", probe.innerWidth, LOCKED.width],
|
|
2506
|
+
["innerHeight (page should see locked height)", probe.innerHeight, LOCKED.height],
|
|
2507
|
+
["view bounds preserved (BrowserView did NOT grow to locked size)", viewBounds.width < LOCKED.width, true]
|
|
2508
|
+
];
|
|
2509
|
+
let pass = true;
|
|
2510
|
+
for (const [label, actual, expected] of checks) {
|
|
2511
|
+
const ok = actual === expected;
|
|
2512
|
+
console.log(` [${ok ? "\u2713" : "\u2717"}] ${label}: ${JSON.stringify(actual)} (expected ${JSON.stringify(expected)})`);
|
|
2513
|
+
if (!ok) pass = false;
|
|
2514
|
+
}
|
|
2515
|
+
console.log(`[scaledviewport-test] expected scale \u2248 ${expectedScale.toFixed(3)}`);
|
|
2516
|
+
console.log(`[scaledviewport-test] ${pass ? "PASS" : "FAIL"}`);
|
|
2517
|
+
import_electron2.app.exit(pass ? 0 : 1);
|
|
2518
|
+
}
|
|
2519
|
+
import_electron2.app.whenReady().then(createWindow);
|
|
2520
|
+
import_electron2.app.on("window-all-closed", () => {
|
|
2521
|
+
if (process.platform !== "darwin") import_electron2.app.quit();
|
|
2522
|
+
});
|
|
2523
|
+
import_electron2.app.on("activate", () => {
|
|
2524
|
+
if (import_electron2.BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
2525
|
+
});
|