objs-core 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +572 -618
- package/objs-extension/README.md +32 -0
- package/objs-extension/background.js +110 -0
- package/objs-extension/bridge.js +193 -0
- package/objs-extension/icons/icon128.png +0 -0
- package/objs-extension/lib/objs-inject.js +5308 -0
- package/objs-extension/manifest.json +18 -0
- package/objs-extension/sidepanel.css +455 -0
- package/objs-extension/sidepanel.html +56 -0
- package/objs-extension/sidepanel.js +908 -0
- package/objs.built.js +475 -120
- package/objs.built.min.js +63 -54
- package/objs.d.ts +584 -525
- package/objs.global.js +5308 -0
- package/objs.global.min.js +98 -0
- package/objs.js +593 -134
- package/package.json +73 -70
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
const STORAGE_KEY = "objsExtTestsV2";
|
|
2
|
+
/** Persists o.tShowOk preference (show “OK” lines in test log / overlay). Legacy: objsExtOverlayShowSucceeded. */
|
|
3
|
+
const TSHOWOK_PREFS_KEY = "objsExtTShowOk";
|
|
4
|
+
const LEGACY_OVERLAY_PREFS_KEY = "objsExtOverlayShowSucceeded";
|
|
5
|
+
const THEME_KEY = "objsExtTheme";
|
|
6
|
+
const RECORDING_SETTINGS_KEY = "objsExtRecordingSettings";
|
|
7
|
+
|
|
8
|
+
const defaultRecordingSettings = {
|
|
9
|
+
useFetchMocks: true,
|
|
10
|
+
useWsMock: true,
|
|
11
|
+
runAssertions: true,
|
|
12
|
+
assertionDebug: false,
|
|
13
|
+
/** Shorthand: strictPlay + all strict* below */
|
|
14
|
+
strictPlay: false,
|
|
15
|
+
strictAssertions: false,
|
|
16
|
+
strictNetwork: false,
|
|
17
|
+
strictWebSocket: false,
|
|
18
|
+
strictRemoved: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Merged defaults + chrome.storage — used for JSON replay / playRecording opts. */
|
|
22
|
+
let recordingSettings = { ...defaultRecordingSettings };
|
|
23
|
+
|
|
24
|
+
const defaultEmptyScript = `// Use Record (http(s) tab behind this popup) or paste o.exportTest() output from the library / recording example.
|
|
25
|
+
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function parseMaybeRecording(text) {
|
|
29
|
+
try {
|
|
30
|
+
const o = JSON.parse(text.trim());
|
|
31
|
+
if (o && Array.isArray(o.actions)) return o;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Display name from a chosen file name (strip path and common extensions). */
|
|
39
|
+
function titleFromFileName(fileName) {
|
|
40
|
+
const base = String(fileName || "").replace(/^.*[/\\]/, "");
|
|
41
|
+
const stripped = base.replace(/\.(js|json|ts|mjs|cjs|jsx|tsx)$/i, "").trim();
|
|
42
|
+
return stripped || "Imported";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Apply imported file text to one test (JSON recording → snapshot + exportTest when possible).
|
|
47
|
+
* @returns {{ exportError?: unknown }} set when JSON was recognized but exportTest failed
|
|
48
|
+
*/
|
|
49
|
+
async function importTextIntoTest(t, text) {
|
|
50
|
+
const jsonRec = parseMaybeRecording(text);
|
|
51
|
+
if (jsonRec) {
|
|
52
|
+
t.recordingSnapshot = jsonRec;
|
|
53
|
+
try {
|
|
54
|
+
const script = await sendInvoke("exportTest", { recording: jsonRec, options: { delay: 16 } });
|
|
55
|
+
t.testScript = script;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
t.testScript = text;
|
|
58
|
+
return { exportError: e };
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
t.testScript = text;
|
|
62
|
+
t.recordingSnapshot = null;
|
|
63
|
+
}
|
|
64
|
+
t.updatedAt = Date.now();
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function canPlayScript(text) {
|
|
69
|
+
const s = text.trim();
|
|
70
|
+
if (!s) return false;
|
|
71
|
+
if (parseMaybeRecording(s)) return true;
|
|
72
|
+
if (s.includes("o.addTest")) {
|
|
73
|
+
if (/o\.addTest\s*\(\s*['"][^'"]*['"]\s*,\s*\[\s*\]/s.test(s)) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
if (s.includes("o.test") && s.includes("__objsExtensionTestRun")) return true;
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sendMessage(msg) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
chrome.runtime.sendMessage(msg, (res) => {
|
|
83
|
+
const err = chrome.runtime.lastError;
|
|
84
|
+
if (err) {
|
|
85
|
+
reject(new Error(err.message));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!res) {
|
|
89
|
+
reject(new Error("No response"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (res.ok) resolve(res);
|
|
93
|
+
else reject(new Error(res.error || "Unknown error"));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function getTabId() {
|
|
99
|
+
const res = await sendMessage({ type: "getTabId" });
|
|
100
|
+
return res.tabId;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Passed to bridge; sets o.tShowOk before run (checked = show succeeded step lines). */
|
|
104
|
+
function overlayArg() {
|
|
105
|
+
const chk = el("chk-show-succeeded");
|
|
106
|
+
const showSucceeded = chk ? chk.checked : true;
|
|
107
|
+
return { overlay: { showSucceeded } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Invoke bridge in the target tab; tabId resolved in service worker if omitted. */
|
|
111
|
+
function sendInvoke(method, arg) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
chrome.runtime.sendMessage({ type: "invoke", tabId: null, method, arg }, (res) => {
|
|
114
|
+
const err = chrome.runtime.lastError;
|
|
115
|
+
if (err) {
|
|
116
|
+
reject(new Error(err.message));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!res) {
|
|
120
|
+
reject(new Error("No response"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (res.ok) resolve(res.result);
|
|
124
|
+
else reject(new Error(res.error || "Unknown error"));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function loadStore() {
|
|
130
|
+
const data = await chrome.storage.local.get(STORAGE_KEY);
|
|
131
|
+
const raw = data[STORAGE_KEY];
|
|
132
|
+
if (raw && raw.tests) {
|
|
133
|
+
raw.tests = raw.tests.map(normalizeTest);
|
|
134
|
+
if (!raw.expandedIds) raw.expandedIds = [];
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
/* migrate from v1 */
|
|
138
|
+
const legacy = await chrome.storage.local.get("objsExtTestsV1");
|
|
139
|
+
const old = legacy.objsExtTestsV1;
|
|
140
|
+
if (old && old.tests) {
|
|
141
|
+
const migrated = {
|
|
142
|
+
tests: old.tests.map((t) =>
|
|
143
|
+
normalizeTest({
|
|
144
|
+
id: t.id,
|
|
145
|
+
name: t.name || "Test",
|
|
146
|
+
observeRoot: t.observeRoot || "",
|
|
147
|
+
autotag: t.autotag,
|
|
148
|
+
testScript: "",
|
|
149
|
+
recordingSnapshot: parseMaybeRecording(t.recordingText || "") || null,
|
|
150
|
+
updatedAt: t.updatedAt || Date.now(),
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
expandedIds: old.activeId ? [old.activeId] : [],
|
|
154
|
+
};
|
|
155
|
+
await chrome.storage.local.set({ [STORAGE_KEY]: migrated });
|
|
156
|
+
return migrated;
|
|
157
|
+
}
|
|
158
|
+
return { tests: [], expandedIds: [] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEFAULT_AUTOTAG = "qa";
|
|
162
|
+
|
|
163
|
+
function normalizeTest(t) {
|
|
164
|
+
const autotag =
|
|
165
|
+
t.autotag !== undefined && t.autotag !== null ? String(t.autotag) : DEFAULT_AUTOTAG;
|
|
166
|
+
if (t.testScript != null && typeof t.testScript === "string") {
|
|
167
|
+
return {
|
|
168
|
+
id: t.id,
|
|
169
|
+
name: t.name || "Untitled",
|
|
170
|
+
observeRoot: t.observeRoot || "",
|
|
171
|
+
autotag,
|
|
172
|
+
testScript: t.testScript,
|
|
173
|
+
recordingSnapshot: t.recordingSnapshot || null,
|
|
174
|
+
updatedAt: t.updatedAt || Date.now(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (t.recordingText && typeof t.recordingText === "string") {
|
|
178
|
+
const snap = parseMaybeRecording(t.recordingText);
|
|
179
|
+
if (snap) {
|
|
180
|
+
return {
|
|
181
|
+
id: t.id,
|
|
182
|
+
name: t.name || "Untitled",
|
|
183
|
+
observeRoot: t.observeRoot || "",
|
|
184
|
+
autotag,
|
|
185
|
+
testScript: "",
|
|
186
|
+
recordingSnapshot: snap,
|
|
187
|
+
updatedAt: t.updatedAt || Date.now(),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
id: t.id,
|
|
193
|
+
name: t.name || "Untitled",
|
|
194
|
+
observeRoot: t.observeRoot || "",
|
|
195
|
+
autotag,
|
|
196
|
+
testScript: t.recordingText || defaultEmptyScript,
|
|
197
|
+
recordingSnapshot: t.recordingSnapshot || null,
|
|
198
|
+
updatedAt: t.updatedAt || Date.now(),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function saveStore(store) {
|
|
203
|
+
await chrome.storage.local.set({ [STORAGE_KEY]: store });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let store = { tests: [], expandedIds: [] };
|
|
207
|
+
let importTargetId = null;
|
|
208
|
+
let saveTimer = null;
|
|
209
|
+
|
|
210
|
+
function scheduleSave() {
|
|
211
|
+
clearTimeout(saveTimer);
|
|
212
|
+
saveTimer = setTimeout(() => {
|
|
213
|
+
saveStore(store).catch(() => {});
|
|
214
|
+
}, 400);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function el(id) {
|
|
218
|
+
return document.getElementById(id);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let statusHideTimer = null;
|
|
222
|
+
|
|
223
|
+
function setStatus(msg) {
|
|
224
|
+
const node = el("status");
|
|
225
|
+
clearTimeout(statusHideTimer);
|
|
226
|
+
node.textContent = msg || "";
|
|
227
|
+
if (msg) {
|
|
228
|
+
node.classList.remove("status--hidden");
|
|
229
|
+
statusHideTimer = setTimeout(() => {
|
|
230
|
+
node.textContent = "";
|
|
231
|
+
node.classList.add("status--hidden");
|
|
232
|
+
}, 5000);
|
|
233
|
+
} else {
|
|
234
|
+
node.classList.add("status--hidden");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isExpanded(id) {
|
|
239
|
+
return store.expandedIds && store.expandedIds.includes(id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function toggleExpanded(id) {
|
|
243
|
+
if (!store.expandedIds) store.expandedIds = [];
|
|
244
|
+
const i = store.expandedIds.indexOf(id);
|
|
245
|
+
if (i >= 0) store.expandedIds.splice(i, 1);
|
|
246
|
+
else store.expandedIds.push(id);
|
|
247
|
+
scheduleSave();
|
|
248
|
+
render();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function testById(id) {
|
|
252
|
+
return store.tests.find((t) => t.id === id);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function updatePlayButton(btn, script) {
|
|
256
|
+
if (!btn) return;
|
|
257
|
+
btn.disabled = !canPlayScript(script || "");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function migrateLegacyScript(t) {
|
|
261
|
+
if (!t.recordingSnapshot || (t.testScript && t.testScript.includes("o.addTest"))) return;
|
|
262
|
+
try {
|
|
263
|
+
const script = await sendInvoke("exportTest", {
|
|
264
|
+
recording: t.recordingSnapshot,
|
|
265
|
+
options: { delay: 16 },
|
|
266
|
+
});
|
|
267
|
+
t.testScript = script;
|
|
268
|
+
t.recordingSnapshot = null;
|
|
269
|
+
await saveStore(store);
|
|
270
|
+
} catch {
|
|
271
|
+
/* no tab yet */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function render() {
|
|
276
|
+
const root = el("accordion-root");
|
|
277
|
+
root.innerHTML = "";
|
|
278
|
+
for (const t of store.tests) {
|
|
279
|
+
const open = isExpanded(t.id);
|
|
280
|
+
const item = document.createElement("div");
|
|
281
|
+
item.className = "acc-item" + (open ? " acc-item--open" : "");
|
|
282
|
+
item.dataset.testId = t.id;
|
|
283
|
+
|
|
284
|
+
const head = document.createElement("div");
|
|
285
|
+
head.className = "acc-head";
|
|
286
|
+
|
|
287
|
+
const chev = document.createElement("button");
|
|
288
|
+
chev.type = "button";
|
|
289
|
+
chev.className = "acc-chevron";
|
|
290
|
+
chev.textContent = "▶";
|
|
291
|
+
chev.title = "Expand / collapse";
|
|
292
|
+
chev.addEventListener("click", () => toggleExpanded(t.id));
|
|
293
|
+
|
|
294
|
+
const nameInp = document.createElement("input");
|
|
295
|
+
nameInp.type = "text";
|
|
296
|
+
nameInp.className = "acc-name";
|
|
297
|
+
nameInp.value = t.name || "";
|
|
298
|
+
nameInp.placeholder = "Test name";
|
|
299
|
+
nameInp.addEventListener("input", () => {
|
|
300
|
+
t.name = nameInp.value;
|
|
301
|
+
t.updatedAt = Date.now();
|
|
302
|
+
scheduleSave();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const btnPlay = document.createElement("button");
|
|
306
|
+
btnPlay.type = "button";
|
|
307
|
+
btnPlay.className = "btn btn--play btn--sm";
|
|
308
|
+
btnPlay.textContent = "Play";
|
|
309
|
+
btnPlay.dataset.action = "play";
|
|
310
|
+
|
|
311
|
+
const btnStop = document.createElement("button");
|
|
312
|
+
btnStop.type = "button";
|
|
313
|
+
btnStop.className = "btn btn--stop btn--sm";
|
|
314
|
+
btnStop.textContent = "Stop";
|
|
315
|
+
btnStop.dataset.action = "stop";
|
|
316
|
+
|
|
317
|
+
const btnRec = document.createElement("button");
|
|
318
|
+
btnRec.type = "button";
|
|
319
|
+
btnRec.className = "btn btn--rec btn--sm";
|
|
320
|
+
btnRec.textContent = "Record";
|
|
321
|
+
btnRec.dataset.action = "record";
|
|
322
|
+
|
|
323
|
+
const btnImp = document.createElement("button");
|
|
324
|
+
btnImp.type = "button";
|
|
325
|
+
btnImp.className = "btn btn--outline btn--sm";
|
|
326
|
+
btnImp.textContent = "Import";
|
|
327
|
+
btnImp.dataset.action = "import";
|
|
328
|
+
|
|
329
|
+
const btnExp = document.createElement("button");
|
|
330
|
+
btnExp.type = "button";
|
|
331
|
+
btnExp.className = "btn btn--outline btn--sm";
|
|
332
|
+
btnExp.textContent = "Export .js";
|
|
333
|
+
btnExp.dataset.action = "export-js";
|
|
334
|
+
|
|
335
|
+
const btnPw = document.createElement("button");
|
|
336
|
+
btnPw.type = "button";
|
|
337
|
+
btnPw.className = "btn btn--outline btn--sm";
|
|
338
|
+
btnPw.textContent = "Playwright";
|
|
339
|
+
btnPw.dataset.action = "export-pw";
|
|
340
|
+
|
|
341
|
+
const btnDel = document.createElement("button");
|
|
342
|
+
btnDel.type = "button";
|
|
343
|
+
btnDel.className = "btn btn--sm acc-head__delete";
|
|
344
|
+
btnDel.textContent = "✕";
|
|
345
|
+
btnDel.title = "Delete test";
|
|
346
|
+
btnDel.dataset.action = "delete";
|
|
347
|
+
|
|
348
|
+
head.appendChild(chev);
|
|
349
|
+
head.appendChild(nameInp);
|
|
350
|
+
head.appendChild(btnPlay);
|
|
351
|
+
head.appendChild(btnDel);
|
|
352
|
+
|
|
353
|
+
const body = document.createElement("div");
|
|
354
|
+
body.className = "acc-body";
|
|
355
|
+
|
|
356
|
+
const obsRow = document.createElement("div");
|
|
357
|
+
obsRow.className = "acc-row";
|
|
358
|
+
const obsLbl = document.createElement("label");
|
|
359
|
+
obsLbl.className = "lbl";
|
|
360
|
+
obsLbl.textContent = "Observe root · o.autotag";
|
|
361
|
+
const obsLine = document.createElement("div");
|
|
362
|
+
obsLine.className = "acc-observe-line";
|
|
363
|
+
const obsInp = document.createElement("input");
|
|
364
|
+
obsInp.type = "text";
|
|
365
|
+
obsInp.className = "inp acc-observe";
|
|
366
|
+
obsInp.placeholder = "#task-app";
|
|
367
|
+
obsInp.title = "CSS selector for MutationObserver scope (optional)";
|
|
368
|
+
obsInp.value = t.observeRoot || "";
|
|
369
|
+
const qaInp = document.createElement("input");
|
|
370
|
+
qaInp.type = "text";
|
|
371
|
+
qaInp.className = "inp acc-autotag";
|
|
372
|
+
qaInp.placeholder = DEFAULT_AUTOTAG;
|
|
373
|
+
qaInp.title = "o.autotag — data-… attribute name (e.g. qa → data-qa)";
|
|
374
|
+
qaInp.value = t.autotag !== undefined && t.autotag !== null ? t.autotag : DEFAULT_AUTOTAG;
|
|
375
|
+
obsLine.appendChild(obsInp);
|
|
376
|
+
obsLine.appendChild(qaInp);
|
|
377
|
+
obsLine.appendChild(btnRec);
|
|
378
|
+
obsLine.appendChild(btnStop);
|
|
379
|
+
obsRow.appendChild(obsLbl);
|
|
380
|
+
obsRow.appendChild(obsLine);
|
|
381
|
+
|
|
382
|
+
const taRow = document.createElement("div");
|
|
383
|
+
taRow.className = "acc-row";
|
|
384
|
+
const taLbl = document.createElement("span");
|
|
385
|
+
taLbl.className = "lbl";
|
|
386
|
+
taLbl.textContent = "Objs test (o.exportTest) — edit freely";
|
|
387
|
+
const ta = document.createElement("textarea");
|
|
388
|
+
ta.className = "ta acc-script";
|
|
389
|
+
ta.spellcheck = false;
|
|
390
|
+
ta.value = t.testScript || defaultEmptyScript;
|
|
391
|
+
taRow.appendChild(taLbl);
|
|
392
|
+
taRow.appendChild(ta);
|
|
393
|
+
|
|
394
|
+
const belowTa = document.createElement("div");
|
|
395
|
+
belowTa.className = "acc-row acc-actions-below";
|
|
396
|
+
belowTa.appendChild(btnImp);
|
|
397
|
+
belowTa.appendChild(btnExp);
|
|
398
|
+
belowTa.appendChild(btnPw);
|
|
399
|
+
|
|
400
|
+
const hint = document.createElement("p");
|
|
401
|
+
hint.className = "hint";
|
|
402
|
+
hint.textContent =
|
|
403
|
+
"Import .js or legacy .json. JSON-only uses Play (slow replay) with fetch/WS mocks; JS from Record uses o.test sync (fast).";
|
|
404
|
+
|
|
405
|
+
body.appendChild(obsRow);
|
|
406
|
+
appendRecordingSettingsSection(body, t.id);
|
|
407
|
+
body.appendChild(taRow);
|
|
408
|
+
body.appendChild(belowTa);
|
|
409
|
+
body.appendChild(hint);
|
|
410
|
+
|
|
411
|
+
obsInp.addEventListener("input", () => {
|
|
412
|
+
t.observeRoot = obsInp.value.trim();
|
|
413
|
+
t.updatedAt = Date.now();
|
|
414
|
+
scheduleSave();
|
|
415
|
+
});
|
|
416
|
+
qaInp.addEventListener("input", () => {
|
|
417
|
+
t.autotag = qaInp.value;
|
|
418
|
+
t.updatedAt = Date.now();
|
|
419
|
+
scheduleSave();
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
ta.addEventListener("input", () => {
|
|
423
|
+
t.testScript = ta.value;
|
|
424
|
+
t.updatedAt = Date.now();
|
|
425
|
+
updatePlayButton(btnPlay, ta.value);
|
|
426
|
+
scheduleSave();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
updatePlayButton(btnPlay, ta.value);
|
|
430
|
+
|
|
431
|
+
item.appendChild(head);
|
|
432
|
+
item.appendChild(body);
|
|
433
|
+
root.appendChild(item);
|
|
434
|
+
|
|
435
|
+
/* wire actions */
|
|
436
|
+
const run = async (ev) => {
|
|
437
|
+
const action = ev.target.closest("[data-action]")?.dataset?.action;
|
|
438
|
+
if (!action) return;
|
|
439
|
+
const id = t.id;
|
|
440
|
+
const cur = testById(id);
|
|
441
|
+
if (!cur) return;
|
|
442
|
+
|
|
443
|
+
if (action === "delete") {
|
|
444
|
+
if (store.tests.length <= 1) {
|
|
445
|
+
setStatus("Keep at least one test.");
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
store.tests = store.tests.filter((x) => x.id !== id);
|
|
449
|
+
store.expandedIds = (store.expandedIds || []).filter((e) => e !== id);
|
|
450
|
+
await saveStore(store);
|
|
451
|
+
render();
|
|
452
|
+
setStatus("Test removed");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (action === "import") {
|
|
457
|
+
importTargetId = id;
|
|
458
|
+
el("file-import-hidden").click();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (action === "export-js") {
|
|
463
|
+
const blob = new Blob([cur.testScript || ""], { type: "text/javascript" });
|
|
464
|
+
const u = URL.createObjectURL(blob);
|
|
465
|
+
const a = document.createElement("a");
|
|
466
|
+
a.href = u;
|
|
467
|
+
a.download = (cur.name || "objs-test").replace(/[^a-z0-9-_]+/gi, "_") + ".js";
|
|
468
|
+
a.click();
|
|
469
|
+
URL.revokeObjectURL(u);
|
|
470
|
+
setStatus("Exported .js");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (action === "export-pw") {
|
|
475
|
+
let rec = cur.recordingSnapshot;
|
|
476
|
+
if (!rec) {
|
|
477
|
+
const p = parseMaybeRecording(cur.testScript || "");
|
|
478
|
+
if (p) rec = p;
|
|
479
|
+
}
|
|
480
|
+
if (!rec) {
|
|
481
|
+
setStatus("Playwright export needs a recording — Record once or import JSON.");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const name = (cur.name || "Recorded").replace(/"/g, '\\"');
|
|
486
|
+
const code = await sendInvoke("exportPlaywrightTest", {
|
|
487
|
+
recording: rec,
|
|
488
|
+
options: { testName: name },
|
|
489
|
+
});
|
|
490
|
+
const blob = new Blob([code], { type: "text/typescript" });
|
|
491
|
+
const u = URL.createObjectURL(blob);
|
|
492
|
+
const a = document.createElement("a");
|
|
493
|
+
a.href = u;
|
|
494
|
+
a.download = (cur.name || "recorded").replace(/[^a-z0-9-_]+/gi, "_") + ".spec.ts";
|
|
495
|
+
a.click();
|
|
496
|
+
URL.revokeObjectURL(u);
|
|
497
|
+
setStatus("Playwright file downloaded");
|
|
498
|
+
} catch (e) {
|
|
499
|
+
setStatus(String(e.message || e));
|
|
500
|
+
}
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await getTabId();
|
|
506
|
+
} catch (e) {
|
|
507
|
+
setStatus(String(e.message || e));
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const obs = obsInp.value.trim();
|
|
512
|
+
const qa = (qaInp.value || "").trim();
|
|
513
|
+
const taEl = item.querySelector(".acc-script");
|
|
514
|
+
const autotagPayload = { autotag: qa };
|
|
515
|
+
|
|
516
|
+
if (action === "record") {
|
|
517
|
+
try {
|
|
518
|
+
await sendInvoke("startRecording", {
|
|
519
|
+
observe: obs || undefined,
|
|
520
|
+
...autotagPayload,
|
|
521
|
+
});
|
|
522
|
+
btnRec.disabled = true;
|
|
523
|
+
btnStop.disabled = false;
|
|
524
|
+
btnPlay.disabled = true;
|
|
525
|
+
setStatus("Recording… use the page, then Stop.");
|
|
526
|
+
} catch (e) {
|
|
527
|
+
setStatus(String(e.message || e));
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (action === "stop") {
|
|
533
|
+
try {
|
|
534
|
+
const out = await sendInvoke("stopAndExport", { options: { delay: 16 } });
|
|
535
|
+
cur.testScript = out.script;
|
|
536
|
+
cur.recordingSnapshot = out.recording;
|
|
537
|
+
cur.observeRoot = obs;
|
|
538
|
+
cur.autotag = qaInp.value;
|
|
539
|
+
cur.updatedAt = Date.now();
|
|
540
|
+
taEl.value = out.script;
|
|
541
|
+
updatePlayButton(btnPlay, out.script);
|
|
542
|
+
await saveStore(store);
|
|
543
|
+
btnRec.disabled = false;
|
|
544
|
+
btnStop.disabled = true;
|
|
545
|
+
const n = out.recording.actions ? out.recording.actions.length : 0;
|
|
546
|
+
setStatus(n ? `Stopped — ${n} steps, Objs test updated` : "Stopped — no actions");
|
|
547
|
+
} catch (e) {
|
|
548
|
+
setStatus(String(e.message || e));
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (action === "play") {
|
|
554
|
+
const text = taEl.value;
|
|
555
|
+
const legacy = parseMaybeRecording(text);
|
|
556
|
+
try {
|
|
557
|
+
setStatus("Running…");
|
|
558
|
+
if (legacy) {
|
|
559
|
+
const out = await sendInvoke("playRecording", {
|
|
560
|
+
recording: legacy,
|
|
561
|
+
opts: playRecordingOptsFromSettings(obs, legacy),
|
|
562
|
+
...autotagPayload,
|
|
563
|
+
...overlayArg(),
|
|
564
|
+
});
|
|
565
|
+
const ar = out && out.assertionResult;
|
|
566
|
+
setStatus(
|
|
567
|
+
ar && ar.total > 0
|
|
568
|
+
? `Replay done — assertions ${ar.passed}/${ar.total}`
|
|
569
|
+
: "Replay finished",
|
|
570
|
+
);
|
|
571
|
+
} else {
|
|
572
|
+
await sendInvoke("runExportedTest", {
|
|
573
|
+
code: text,
|
|
574
|
+
...autotagPayload,
|
|
575
|
+
...overlayArg(),
|
|
576
|
+
});
|
|
577
|
+
setStatus("Objs test finished — see overlay on the page");
|
|
578
|
+
}
|
|
579
|
+
} catch (e) {
|
|
580
|
+
setStatus(String(e.message || e));
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
item.addEventListener("click", run);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/* recorder state refresh */
|
|
590
|
+
refreshAllRecorderStates();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function refreshAllRecorderStates() {
|
|
594
|
+
try {
|
|
595
|
+
await getTabId();
|
|
596
|
+
const active = await sendInvoke("recorderActive", undefined);
|
|
597
|
+
for (const item of document.querySelectorAll(".acc-item")) {
|
|
598
|
+
const id = item.dataset.testId;
|
|
599
|
+
const t = testById(id);
|
|
600
|
+
if (!t) continue;
|
|
601
|
+
const btnRec = item.querySelector('[data-action="record"]');
|
|
602
|
+
const btnStop = item.querySelector('[data-action="stop"]');
|
|
603
|
+
const ta = item.querySelector(".acc-script");
|
|
604
|
+
const btnPlay = item.querySelector('[data-action="play"]');
|
|
605
|
+
if (btnRec && btnStop) {
|
|
606
|
+
btnRec.disabled = !!active;
|
|
607
|
+
btnStop.disabled = !active;
|
|
608
|
+
}
|
|
609
|
+
if (btnPlay && ta) {
|
|
610
|
+
updatePlayButton(btnPlay, ta.value);
|
|
611
|
+
if (active) btnPlay.disabled = true;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
for (const btnRec of document.querySelectorAll('[data-action="record"]')) {
|
|
616
|
+
btnRec.disabled = false;
|
|
617
|
+
}
|
|
618
|
+
for (const btnStop of document.querySelectorAll('[data-action="stop"]')) {
|
|
619
|
+
btnStop.disabled = true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async function loadTShowOkPrefs() {
|
|
625
|
+
const data = await chrome.storage.local.get([TSHOWOK_PREFS_KEY, LEGACY_OVERLAY_PREFS_KEY]);
|
|
626
|
+
const chk = el("chk-show-succeeded");
|
|
627
|
+
if (!chk) return;
|
|
628
|
+
if (data[TSHOWOK_PREFS_KEY] !== undefined) {
|
|
629
|
+
chk.checked = data[TSHOWOK_PREFS_KEY] !== false;
|
|
630
|
+
} else if (data[LEGACY_OVERLAY_PREFS_KEY] !== undefined) {
|
|
631
|
+
chk.checked = data[LEGACY_OVERLAY_PREFS_KEY] !== false;
|
|
632
|
+
} else {
|
|
633
|
+
chk.checked = true;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function applyTheme(theme) {
|
|
638
|
+
const th = theme === "light" || theme === "dark" ? theme : "dark";
|
|
639
|
+
document.documentElement.setAttribute("data-theme", th);
|
|
640
|
+
const btn = el("btn-theme");
|
|
641
|
+
if (btn) {
|
|
642
|
+
if (th === "dark") {
|
|
643
|
+
btn.textContent = "☀";
|
|
644
|
+
btn.title = "Switch to light theme";
|
|
645
|
+
btn.setAttribute("aria-label", "Switch to light theme");
|
|
646
|
+
} else {
|
|
647
|
+
btn.textContent = "🌙";
|
|
648
|
+
btn.title = "Switch to dark theme";
|
|
649
|
+
btn.setAttribute("aria-label", "Switch to dark theme");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function initTheme() {
|
|
655
|
+
const data = await chrome.storage.local.get(THEME_KEY);
|
|
656
|
+
const saved = data[THEME_KEY];
|
|
657
|
+
applyTheme(saved === "light" || saved === "dark" ? saved : "dark");
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function loadRecordingSettings() {
|
|
661
|
+
const data = await chrome.storage.local.get(RECORDING_SETTINGS_KEY);
|
|
662
|
+
const s = data[RECORDING_SETTINGS_KEY];
|
|
663
|
+
if (s && typeof s === "object") {
|
|
664
|
+
recordingSettings = { ...defaultRecordingSettings, ...s };
|
|
665
|
+
} else {
|
|
666
|
+
recordingSettings = { ...defaultRecordingSettings };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function persistRecordingSettings() {
|
|
671
|
+
return chrome.storage.local.set({ [RECORDING_SETTINGS_KEY]: recordingSettings });
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function playRecordingOptsFromSettings(obs, legacy) {
|
|
675
|
+
const o = {
|
|
676
|
+
root: obs || legacy.observeRoot || undefined,
|
|
677
|
+
actionDelay: 120,
|
|
678
|
+
runAssertions: recordingSettings.runAssertions,
|
|
679
|
+
skipWebSocketMock: !recordingSettings.useWsMock,
|
|
680
|
+
skipNetworkMocks: !recordingSettings.useFetchMocks,
|
|
681
|
+
recordingAssertionDebug: recordingSettings.assertionDebug,
|
|
682
|
+
};
|
|
683
|
+
if (recordingSettings.strictPlay) o.strictPlay = true;
|
|
684
|
+
else {
|
|
685
|
+
if (recordingSettings.strictAssertions) o.strictAssertions = true;
|
|
686
|
+
if (recordingSettings.strictNetwork) o.strictNetwork = true;
|
|
687
|
+
if (recordingSettings.strictWebSocket) o.strictWebSocket = true;
|
|
688
|
+
if (recordingSettings.strictRemoved) o.strictRemoved = true;
|
|
689
|
+
}
|
|
690
|
+
return o;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function appendRecordingSettingsSection(body, testId) {
|
|
694
|
+
const details = document.createElement("details");
|
|
695
|
+
details.className = "acc-rec-settings";
|
|
696
|
+
const sum = document.createElement("summary");
|
|
697
|
+
sum.className = "acc-rec-settings__summary";
|
|
698
|
+
sum.textContent = "Recording settings";
|
|
699
|
+
details.appendChild(sum);
|
|
700
|
+
|
|
701
|
+
const inner = document.createElement("div");
|
|
702
|
+
inner.className = "acc-rec-settings__inner";
|
|
703
|
+
|
|
704
|
+
const addRow = (key, labelText) => {
|
|
705
|
+
const id = `rs-${key}-${testId}`;
|
|
706
|
+
const lab = document.createElement("label");
|
|
707
|
+
lab.className = "acc-rec-settings__row";
|
|
708
|
+
lab.htmlFor = id;
|
|
709
|
+
const cb = document.createElement("input");
|
|
710
|
+
cb.type = "checkbox";
|
|
711
|
+
cb.id = id;
|
|
712
|
+
cb.checked = !!recordingSettings[key];
|
|
713
|
+
cb.addEventListener("change", () => {
|
|
714
|
+
recordingSettings[key] = cb.checked;
|
|
715
|
+
persistRecordingSettings().catch(() => {});
|
|
716
|
+
render();
|
|
717
|
+
});
|
|
718
|
+
const span = document.createElement("span");
|
|
719
|
+
span.textContent = labelText;
|
|
720
|
+
lab.appendChild(cb);
|
|
721
|
+
lab.appendChild(span);
|
|
722
|
+
inner.appendChild(lab);
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
addRow("useFetchMocks", "Replay fetch / XHR mocks (off = live network)");
|
|
726
|
+
addRow("useWsMock", "Replay WebSocket mock when recording includes WS");
|
|
727
|
+
addRow("runAssertions", "Run DOM assertions during JSON replay");
|
|
728
|
+
addRow("assertionDebug", "Log assertion debug to console (o.recordingAssertionDebug)");
|
|
729
|
+
addRow("strictPlay", "Strict replay: DOM + request body + WebSocket sends (see o.playRecording)");
|
|
730
|
+
addRow("strictAssertions", "Strict DOM only (exact list index/text/style; implies strict removed unless off below)");
|
|
731
|
+
addRow("strictNetwork", "Strict network only (mock request body must match)");
|
|
732
|
+
addRow("strictWebSocket", "Strict WebSocket only (outbound frames must match)");
|
|
733
|
+
addRow("strictRemoved", "Strict removed-elements check (verify absence, not auto-pass)");
|
|
734
|
+
|
|
735
|
+
details.appendChild(inner);
|
|
736
|
+
body.appendChild(details);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function init() {
|
|
740
|
+
await initTheme();
|
|
741
|
+
await loadRecordingSettings();
|
|
742
|
+
await loadTShowOkPrefs();
|
|
743
|
+
const chkSucceeded = el("chk-show-succeeded");
|
|
744
|
+
if (chkSucceeded) {
|
|
745
|
+
chkSucceeded.addEventListener("change", () => {
|
|
746
|
+
chrome.storage.local.set({ [TSHOWOK_PREFS_KEY]: chkSucceeded.checked });
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
store = await loadStore();
|
|
751
|
+
if (store.tests.length === 0) {
|
|
752
|
+
const id = crypto.randomUUID();
|
|
753
|
+
store.tests.push({
|
|
754
|
+
id,
|
|
755
|
+
name: "First test",
|
|
756
|
+
observeRoot: "",
|
|
757
|
+
autotag: DEFAULT_AUTOTAG,
|
|
758
|
+
testScript: defaultEmptyScript,
|
|
759
|
+
recordingSnapshot: null,
|
|
760
|
+
updatedAt: Date.now(),
|
|
761
|
+
});
|
|
762
|
+
store.expandedIds = [];
|
|
763
|
+
await saveStore(store);
|
|
764
|
+
}
|
|
765
|
+
if (!store.expandedIds) {
|
|
766
|
+
store.expandedIds = [];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
render();
|
|
770
|
+
|
|
771
|
+
for (const t of store.tests) {
|
|
772
|
+
await migrateLegacyScript(t);
|
|
773
|
+
}
|
|
774
|
+
if (store.tests.some((t) => t.testScript && t.testScript.includes("o.addTest"))) {
|
|
775
|
+
render();
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
el("btn-new-global").addEventListener("click", async () => {
|
|
779
|
+
const id = crypto.randomUUID();
|
|
780
|
+
store.tests.push({
|
|
781
|
+
id,
|
|
782
|
+
name: "New test",
|
|
783
|
+
observeRoot: "",
|
|
784
|
+
autotag: DEFAULT_AUTOTAG,
|
|
785
|
+
testScript: defaultEmptyScript,
|
|
786
|
+
recordingSnapshot: null,
|
|
787
|
+
updatedAt: Date.now(),
|
|
788
|
+
});
|
|
789
|
+
store.expandedIds = store.expandedIds || [];
|
|
790
|
+
await saveStore(store);
|
|
791
|
+
render();
|
|
792
|
+
setStatus("New test");
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
el("file-import-hidden").addEventListener("change", async (ev) => {
|
|
796
|
+
const f = ev.target.files && ev.target.files[0];
|
|
797
|
+
if (f) ev.target.value = "";
|
|
798
|
+
if (!f || !importTargetId) return;
|
|
799
|
+
const text = await f.text();
|
|
800
|
+
const t = testById(importTargetId);
|
|
801
|
+
importTargetId = null;
|
|
802
|
+
if (!t) return;
|
|
803
|
+
|
|
804
|
+
const r = await importTextIntoTest(t, text);
|
|
805
|
+
if (r.exportError) {
|
|
806
|
+
setStatus("Could not convert JSON — stored as text. " + (r.exportError.message || r.exportError));
|
|
807
|
+
} else {
|
|
808
|
+
setStatus("Imported");
|
|
809
|
+
}
|
|
810
|
+
await saveStore(store);
|
|
811
|
+
render();
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
el("btn-import-global").addEventListener("click", () => {
|
|
815
|
+
el("file-import-multi-hidden").click();
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
el("file-import-multi-hidden").addEventListener("change", (ev) => {
|
|
819
|
+
void (async () => {
|
|
820
|
+
/* Snapshot File objects before clearing input — Chrome empties the live FileList on reset. */
|
|
821
|
+
const files = Array.from(ev.target.files || []);
|
|
822
|
+
ev.target.value = "";
|
|
823
|
+
if (files.length === 0) return;
|
|
824
|
+
|
|
825
|
+
try {
|
|
826
|
+
let exportErrors = 0;
|
|
827
|
+
const newIds = [];
|
|
828
|
+
for (let i = 0; i < files.length; i++) {
|
|
829
|
+
const f = files[i];
|
|
830
|
+
let text;
|
|
831
|
+
try {
|
|
832
|
+
text = await f.text();
|
|
833
|
+
} catch (e) {
|
|
834
|
+
setStatus("Could not read file: " + (f.name || "") + " — " + (e.message || e));
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const id = crypto.randomUUID();
|
|
838
|
+
const t = {
|
|
839
|
+
id,
|
|
840
|
+
name: titleFromFileName(f.name),
|
|
841
|
+
observeRoot: "",
|
|
842
|
+
autotag: DEFAULT_AUTOTAG,
|
|
843
|
+
testScript: defaultEmptyScript,
|
|
844
|
+
recordingSnapshot: null,
|
|
845
|
+
updatedAt: Date.now(),
|
|
846
|
+
};
|
|
847
|
+
const r = await importTextIntoTest(t, text);
|
|
848
|
+
if (r.exportError) exportErrors += 1;
|
|
849
|
+
store.tests.push(t);
|
|
850
|
+
newIds.push(id);
|
|
851
|
+
}
|
|
852
|
+
store.expandedIds = [...(store.expandedIds || []), ...newIds];
|
|
853
|
+
await saveStore(store);
|
|
854
|
+
render();
|
|
855
|
+
const n = files.length;
|
|
856
|
+
if (exportErrors > 0) {
|
|
857
|
+
setStatus(
|
|
858
|
+
`Imported ${n} test${n === 1 ? "" : "s"} — ${exportErrors} JSON file(s) kept as text (exportTest unavailable or failed)`,
|
|
859
|
+
);
|
|
860
|
+
} else {
|
|
861
|
+
setStatus(n === 1 ? `Imported 1 test — ${titleFromFileName(files[0].name)}` : `Imported ${n} tests`);
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
setStatus("Import failed: " + (e && e.message ? e.message : String(e)));
|
|
865
|
+
}
|
|
866
|
+
})();
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
setInterval(() => refreshAllRecorderStates().catch(() => {}), 1500);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Delegated so theme works even if async init() stalls before direct listeners run. */
|
|
873
|
+
function wireThemeToggle() {
|
|
874
|
+
document.body.addEventListener("click", async (e) => {
|
|
875
|
+
const btn = e.target && e.target.closest && e.target.closest("#btn-theme");
|
|
876
|
+
if (!btn) return;
|
|
877
|
+
e.preventDefault();
|
|
878
|
+
const cur = document.documentElement.getAttribute("data-theme") || "dark";
|
|
879
|
+
const next = cur === "dark" ? "light" : "dark";
|
|
880
|
+
try {
|
|
881
|
+
await chrome.storage.local.set({ [THEME_KEY]: next });
|
|
882
|
+
} catch {
|
|
883
|
+
/* storage unavailable */
|
|
884
|
+
}
|
|
885
|
+
applyTheme(next);
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
wireThemeToggle();
|
|
890
|
+
|
|
891
|
+
/** Open o.testOverlay on the active tab — delegated so it works even if init stalls. */
|
|
892
|
+
function wireShowOverlayButton() {
|
|
893
|
+
document.body.addEventListener("click", async (e) => {
|
|
894
|
+
const btn = e.target.closest("#btn-show-overlay");
|
|
895
|
+
if (!btn) return;
|
|
896
|
+
e.preventDefault();
|
|
897
|
+
try {
|
|
898
|
+
await getTabId();
|
|
899
|
+
await sendInvoke("showTestOverlay", overlayArg());
|
|
900
|
+
setStatus("Test results opened on the page");
|
|
901
|
+
} catch (err) {
|
|
902
|
+
setStatus(String(err.message || err));
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
wireShowOverlayButton();
|
|
908
|
+
init().catch((e) => setStatus(String(e.message || e)));
|