browser-extension-manager 1.3.49 → 1.4.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 +109 -70
- package/dist/background.js +4 -0
- package/dist/build.js +3 -0
- package/dist/cli.js +1 -0
- package/dist/commands/test.js +55 -0
- package/dist/content.js +4 -0
- package/dist/defaults/CLAUDE.md +66 -6
- package/dist/gulp/tasks/defaults.js +15 -2
- package/dist/offscreen.js +4 -0
- package/dist/options.js +4 -0
- package/dist/page.js +4 -0
- package/dist/popup.js +4 -0
- package/dist/sidepanel.js +4 -0
- package/dist/test/assert.js +120 -0
- package/dist/test/fixtures/consumer-extension/dist/background.js +15 -0
- package/dist/test/fixtures/consumer-extension/dist/manifest.json +20 -0
- package/dist/test/fixtures/consumer-extension/dist/options.html +10 -0
- package/dist/test/fixtures/consumer-extension/dist/popup.html +11 -0
- package/dist/test/harness/extension/background.js +26 -0
- package/dist/test/harness/extension/manifest.json +27 -0
- package/dist/test/harness/extension/options.html +12 -0
- package/dist/test/harness/extension/popup.html +12 -0
- package/dist/test/harness/extension/sidepanel.html +12 -0
- package/dist/test/index.js +63 -0
- package/dist/test/runner.js +407 -0
- package/dist/test/runners/boot.js +201 -0
- package/dist/test/runners/chromium.js +399 -0
- package/dist/test/suites/background/messaging.test.js +42 -0
- package/dist/test/suites/background/storage.test.js +44 -0
- package/dist/test/suites/background/sw-context.test.js +47 -0
- package/dist/test/suites/boot/extension-loads.test.js +56 -0
- package/dist/test/suites/build/affiliatizer.test.js +63 -0
- package/dist/test/suites/build/cli.test.js +123 -0
- package/dist/test/suites/build/expect.test.js +47 -0
- package/dist/test/suites/build/exports.test.js +52 -0
- package/dist/test/suites/build/extension-fallback.test.js +41 -0
- package/dist/test/suites/build/logger-lite.test.js +59 -0
- package/dist/test/suites/build/manager.test.js +162 -0
- package/dist/test/suites/build/mode-helpers.test.js +96 -0
- package/dist/test/suites/view/options-and-sidepanel.test.js +14 -0
- package/dist/test/suites/view/popup-context.test.js +51 -0
- package/dist/test/suites/view/sidepanel.test.js +14 -0
- package/dist/utils/mode-helpers.js +92 -0
- package/package.json +7 -4
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Chromium-runner — launches Puppeteer with the BXM harness extension loaded,
|
|
2
|
+
// runs background-layer suites in the SW context via CDP Runtime.evaluate, then
|
|
3
|
+
// runs view-layer suites in tabs pointed at popup/options/sidepanel pages.
|
|
4
|
+
//
|
|
5
|
+
// Communication channel: each injected test wraps its events as
|
|
6
|
+
// console.log('__BXM_TEST__' + JSON.stringify(evt))
|
|
7
|
+
// from inside the SW / tab. The runner subscribes to `Runtime.consoleAPICalled`
|
|
8
|
+
// (for SW) and Puppeteer's `page.on('console')` (for tabs) and parses those
|
|
9
|
+
// lines exactly like EM's electron runner parses stdout. Same JSON-line
|
|
10
|
+
// protocol — different transport.
|
|
11
|
+
//
|
|
12
|
+
// Test source is shipped as a string. Each test's `run` function body is
|
|
13
|
+
// extracted at load-time and wrapped as `(async (ctx) => { <body> })(ctx)`
|
|
14
|
+
// inside an outer harness that constructs `ctx` + `expect` from inline
|
|
15
|
+
// assert.js source. The body has no closure to its file — it must `require`
|
|
16
|
+
// nothing and rely only on `ctx` + globals (`chrome`, `globalThis`).
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const chalk = require('chalk').default;
|
|
21
|
+
|
|
22
|
+
// Inline the source of assert.js so we can build it into the injected harness
|
|
23
|
+
// payload. The runner reads it from disk once at module-load time.
|
|
24
|
+
const ASSERT_SRC = fs.readFileSync(path.join(__dirname, '..', 'assert.js'), 'utf8');
|
|
25
|
+
|
|
26
|
+
async function runChromiumTests({ backgroundSuiteFiles, viewSuiteFiles, filter, projectRoot, bxmDistRoot }) {
|
|
27
|
+
let puppeteer;
|
|
28
|
+
try {
|
|
29
|
+
puppeteer = require('puppeteer');
|
|
30
|
+
} catch (e) {
|
|
31
|
+
const skipped = backgroundSuiteFiles.length + viewSuiteFiles.length;
|
|
32
|
+
console.log(chalk.yellow(` ○ background + view tests skipped (puppeteer not installed)`));
|
|
33
|
+
return { passed: 0, failed: 0, skipped };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const harnessExt = path.join(bxmDistRoot, 'test', 'harness', 'extension');
|
|
37
|
+
if (!fs.existsSync(path.join(harnessExt, 'manifest.json'))) {
|
|
38
|
+
console.log(chalk.yellow(` ○ background + view tests skipped (harness extension not built at ${harnessExt})`));
|
|
39
|
+
return { passed: 0, failed: 0, skipped: backgroundSuiteFiles.length + viewSuiteFiles.length };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
43
|
+
|
|
44
|
+
// Chromium-with-extensions requires the "new" headless mode (--headless=new in CLI
|
|
45
|
+
// terms). MV3 SWs don't start in old headless. Puppeteer sets it for us when we
|
|
46
|
+
// pass headless: 'new'.
|
|
47
|
+
const browser = await puppeteer.launch({
|
|
48
|
+
headless: 'new',
|
|
49
|
+
args: [
|
|
50
|
+
`--disable-extensions-except=${harnessExt}`,
|
|
51
|
+
`--load-extension=${harnessExt}`,
|
|
52
|
+
'--no-sandbox',
|
|
53
|
+
'--disable-dev-shm-usage',
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Locate the SW target. Chromium spawns it asynchronously; poll for up to 5s.
|
|
59
|
+
const swTarget = await waitForTarget(browser, (t) => t.type() === 'service_worker' && t.url().includes('background.js'), 5000);
|
|
60
|
+
if (!swTarget) {
|
|
61
|
+
console.log(chalk.red(` ✗ Harness service worker never came up — aborting browser layer.`));
|
|
62
|
+
const total = backgroundSuiteFiles.length + viewSuiteFiles.length;
|
|
63
|
+
return { passed: 0, failed: total, skipped: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Background suites first.
|
|
67
|
+
if (backgroundSuiteFiles.length > 0) {
|
|
68
|
+
const r = await runBackgroundSuites({
|
|
69
|
+
browser,
|
|
70
|
+
swTarget,
|
|
71
|
+
suiteFiles: backgroundSuiteFiles,
|
|
72
|
+
filter,
|
|
73
|
+
});
|
|
74
|
+
counts.passed += r.passed;
|
|
75
|
+
counts.failed += r.failed;
|
|
76
|
+
counts.skipped += r.skipped;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// View suites next — popup/options/sidepanel are all loaded as plain
|
|
80
|
+
// chrome-extension:// pages. Same Chromium instance, fresh tab per suite.
|
|
81
|
+
if (viewSuiteFiles.length > 0) {
|
|
82
|
+
const extId = swTarget.url().split('/')[2]; // chrome-extension://<id>/background.js
|
|
83
|
+
const r = await runViewSuites({
|
|
84
|
+
browser,
|
|
85
|
+
extId,
|
|
86
|
+
suiteFiles: viewSuiteFiles,
|
|
87
|
+
filter,
|
|
88
|
+
});
|
|
89
|
+
counts.passed += r.passed;
|
|
90
|
+
counts.failed += r.failed;
|
|
91
|
+
counts.skipped += r.skipped;
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
try { await browser.close(); } catch (_) { /* ignore */ }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return counts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Background layer ────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async function runBackgroundSuites({ browser, swTarget, suiteFiles, filter }) {
|
|
103
|
+
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
104
|
+
|
|
105
|
+
// Attach a CDP session to the SW so we can Runtime.evaluate inside it.
|
|
106
|
+
// Puppeteer's WorkerTarget exposes `.worker()` for accessing the underlying
|
|
107
|
+
// Worker handle.
|
|
108
|
+
const worker = await swTarget.worker();
|
|
109
|
+
if (!worker) {
|
|
110
|
+
console.log(chalk.red(` ✗ Could not attach to harness service worker.`));
|
|
111
|
+
return { passed: 0, failed: suiteFiles.length, skipped: 0 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Subscribe to console output from the SW. Each '__BXM_TEST__...' line is a
|
|
115
|
+
// structured test event. Anything else is incidental SW logging — surface it
|
|
116
|
+
// in BXM_TEST_DEBUG mode.
|
|
117
|
+
const consoleHandler = (msg) => {
|
|
118
|
+
const text = msg.text();
|
|
119
|
+
if (text.startsWith('__BXM_TEST__')) {
|
|
120
|
+
handleConsoleLine(text, counts);
|
|
121
|
+
} else if (process.env.BXM_TEST_DEBUG) {
|
|
122
|
+
process.stdout.write(chalk.gray(` [sw:${msg.type()}] ${text}\n`));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
worker.on('console', consoleHandler);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
for (const file of suiteFiles) {
|
|
129
|
+
let mod;
|
|
130
|
+
try {
|
|
131
|
+
delete require.cache[require.resolve(file)];
|
|
132
|
+
mod = require(file);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
console.log(chalk.red(` ✗ ${file}: Failed to load: ${e.message}`));
|
|
135
|
+
counts.failed += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(mod)) mod = { type: 'group', tests: mod };
|
|
139
|
+
if (mod.layer !== 'background') continue;
|
|
140
|
+
|
|
141
|
+
const suiteName = mod.description || path.basename(file);
|
|
142
|
+
console.log(chalk.cyan(` ⤷ ${suiteName}`));
|
|
143
|
+
|
|
144
|
+
if (mod.skip) {
|
|
145
|
+
const count = Array.isArray(mod.tests) ? mod.tests.length : 1;
|
|
146
|
+
console.log(chalk.yellow(` ○ ${suiteName}`) + chalk.gray(` (skipped)`));
|
|
147
|
+
counts.skipped += count;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const isSuite = mod.type === 'suite' || mod.type === 'group' || Array.isArray(mod.tests);
|
|
152
|
+
const tests = isSuite ? (mod.tests || []) : [{ name: suiteName, run: mod.run, timeout: mod.timeout }];
|
|
153
|
+
const isGroup = mod.type === 'group';
|
|
154
|
+
const stopOnFailure = !isGroup && isSuite && mod.stopOnFailure !== false;
|
|
155
|
+
|
|
156
|
+
// Build a single payload that runs every test in the suite sequentially
|
|
157
|
+
// inside the SW. Shared `state` lives inside the SW for the lifetime of
|
|
158
|
+
// the suite. The runner emits one __BXM_TEST__ event per test.
|
|
159
|
+
const payload = buildSuitePayload({ suiteName, tests, filter, stopOnFailure, timeout: mod.timeout });
|
|
160
|
+
try {
|
|
161
|
+
await worker.evaluate(payload);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// worker.evaluate rejects on syntax errors / top-level throws inside
|
|
164
|
+
// the SW. The injected harness traps per-test errors itself; a
|
|
165
|
+
// rejection here means the wrapper code itself broke.
|
|
166
|
+
console.log(chalk.red(` ✗ ${suiteName}: harness threw: ${e.message}`));
|
|
167
|
+
counts.failed += tests.length;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
worker.off('console', consoleHandler);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return counts;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── View layer ───────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async function runViewSuites({ browser, extId, suiteFiles, filter }) {
|
|
180
|
+
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
181
|
+
|
|
182
|
+
for (const file of suiteFiles) {
|
|
183
|
+
let mod;
|
|
184
|
+
try {
|
|
185
|
+
delete require.cache[require.resolve(file)];
|
|
186
|
+
mod = require(file);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.log(chalk.red(` ✗ ${file}: Failed to load: ${e.message}`));
|
|
189
|
+
counts.failed += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(mod)) mod = { type: 'group', tests: mod };
|
|
193
|
+
if (mod.layer !== 'view') continue;
|
|
194
|
+
|
|
195
|
+
const suiteName = mod.description || path.basename(file);
|
|
196
|
+
const context = mod.context || 'popup'; // popup | options | sidepanel
|
|
197
|
+
console.log(chalk.cyan(` ⤷ ${suiteName} (${context})`));
|
|
198
|
+
|
|
199
|
+
if (mod.skip) {
|
|
200
|
+
const count = Array.isArray(mod.tests) ? mod.tests.length : 1;
|
|
201
|
+
console.log(chalk.yellow(` ○ ${suiteName}`) + chalk.gray(` (skipped)`));
|
|
202
|
+
counts.skipped += count;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const isSuite = mod.type === 'suite' || mod.type === 'group' || Array.isArray(mod.tests);
|
|
207
|
+
const tests = isSuite ? (mod.tests || []) : [{ name: suiteName, run: mod.run, timeout: mod.timeout }];
|
|
208
|
+
const isGroup = mod.type === 'group';
|
|
209
|
+
const stopOnFailure = !isGroup && isSuite && mod.stopOnFailure !== false;
|
|
210
|
+
|
|
211
|
+
const url = `chrome-extension://${extId}/${context}.html`;
|
|
212
|
+
const page = await browser.newPage();
|
|
213
|
+
const consoleHandler = (msg) => {
|
|
214
|
+
const text = msg.text();
|
|
215
|
+
if (text.startsWith('__BXM_TEST__')) {
|
|
216
|
+
handleConsoleLine(text, counts);
|
|
217
|
+
} else if (process.env.BXM_TEST_DEBUG) {
|
|
218
|
+
process.stdout.write(chalk.gray(` [tab:${msg.type()}] ${text}\n`));
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
page.on('console', consoleHandler);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
|
225
|
+
const payload = buildSuitePayload({ suiteName, tests, filter, stopOnFailure, timeout: mod.timeout });
|
|
226
|
+
await page.evaluate(payload);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.log(chalk.red(` ✗ ${suiteName}: harness threw: ${e.message}`));
|
|
229
|
+
counts.failed += tests.length;
|
|
230
|
+
} finally {
|
|
231
|
+
page.off('console', consoleHandler);
|
|
232
|
+
try { await page.close(); } catch (_) { /* ignore */ }
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return counts;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ─── Suite payload builder ────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
// Build a single string of JavaScript that, when evaluated inside the SW or a tab,
|
|
242
|
+
// runs all `tests` sequentially and emits __BXM_TEST__ events per result.
|
|
243
|
+
//
|
|
244
|
+
// Why string-payload vs function-passing? Puppeteer can pass functions, but
|
|
245
|
+
// `worker.evaluate(fn, ...args)` serializes args via JSON (no functions). So
|
|
246
|
+
// every test function body must be string-ified and rebuilt inside the target
|
|
247
|
+
// context. We do that here.
|
|
248
|
+
function buildSuitePayload({ suiteName, tests, filter, stopOnFailure, timeout: suiteTimeout }) {
|
|
249
|
+
// MV3 service workers have a strict CSP that forbids `eval`, `new Function`,
|
|
250
|
+
// `new AsyncFunction` — anything that compiles a string into code at runtime.
|
|
251
|
+
// So we CANNOT rebuild test functions from a string inside the SW.
|
|
252
|
+
//
|
|
253
|
+
// Instead: bake each test's source as a literal async-function expression
|
|
254
|
+
// directly into the payload at runner build-time. The CSP allows that because
|
|
255
|
+
// the SW only sees pre-compiled code arriving via the top-level Runtime.evaluate
|
|
256
|
+
// (which CDP exempts from CSP) — no inner `eval` happens.
|
|
257
|
+
//
|
|
258
|
+
// Each test becomes an entry like:
|
|
259
|
+
// { name: 'foo', timeout: 30000, fn: async (ctx, expect, state) => { /* body */ } }
|
|
260
|
+
// and is called via `await tests[i].fn(ctx, expect, state)`.
|
|
261
|
+
const inlinedTests = tests
|
|
262
|
+
.filter((t) => !filter || t.name.includes(filter) || suiteName.includes(filter))
|
|
263
|
+
.map((t) => {
|
|
264
|
+
const body = extractFnBody(t.run);
|
|
265
|
+
const skip = t.skip ? JSON.stringify(t.skip) : 'false';
|
|
266
|
+
const tout = t.timeout || suiteTimeout || 30000;
|
|
267
|
+
return ` { name: ${JSON.stringify(t.name)}, skip: ${skip}, timeout: ${tout}, fn: async (ctx, expect, state) => {\n${body}\n} },`;
|
|
268
|
+
})
|
|
269
|
+
.join('\n');
|
|
270
|
+
|
|
271
|
+
// assert.js declares `function expect(...) { ... }` at the top level. We strip its
|
|
272
|
+
// `module.exports = expect` line (no `module` in the browser) and keep the function
|
|
273
|
+
// declaration available as the local `expect`.
|
|
274
|
+
return `
|
|
275
|
+
(async function () {
|
|
276
|
+
'use strict';
|
|
277
|
+
${ASSERT_SRC.replace(/module\.exports\s*=\s*expect;?/, '')}
|
|
278
|
+
|
|
279
|
+
function emit(evt) {
|
|
280
|
+
console.log('__BXM_TEST__' + JSON.stringify(evt));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
class SkipError extends Error { constructor(reason) { super(reason); this.name = 'SkipError'; } }
|
|
284
|
+
|
|
285
|
+
const suiteName = ${JSON.stringify(suiteName)};
|
|
286
|
+
const stopOnFail = ${JSON.stringify(!!stopOnFailure)};
|
|
287
|
+
const tests = [
|
|
288
|
+
${inlinedTests}
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
emit({ event: 'suite-start', name: suiteName });
|
|
292
|
+
|
|
293
|
+
const state = {};
|
|
294
|
+
let passed = 0, failed = 0, skipped = 0;
|
|
295
|
+
|
|
296
|
+
for (let i = 0; i < tests.length; i++) {
|
|
297
|
+
const t = tests[i];
|
|
298
|
+
if (t.skip) {
|
|
299
|
+
const reason = typeof t.skip === 'string' ? t.skip : 'skipped';
|
|
300
|
+
emit({ event: 'skip', name: suiteName + ' → ' + t.name, reason });
|
|
301
|
+
skipped += 1;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ctx = {
|
|
306
|
+
expect,
|
|
307
|
+
state,
|
|
308
|
+
layer: 'browser',
|
|
309
|
+
skip(reason) { throw new SkipError(reason || 'skipped at runtime'); },
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const start = Date.now();
|
|
313
|
+
try {
|
|
314
|
+
await Promise.race([
|
|
315
|
+
t.fn(ctx, expect, state),
|
|
316
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Test timeout')), t.timeout)),
|
|
317
|
+
]);
|
|
318
|
+
const duration = Date.now() - start;
|
|
319
|
+
emit({ event: 'result', name: t.name, passed: true, duration });
|
|
320
|
+
passed += 1;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
const duration = Date.now() - start;
|
|
323
|
+
if (e && e.name === 'SkipError') {
|
|
324
|
+
emit({ event: 'skip', name: suiteName + ' → ' + t.name, reason: e.message });
|
|
325
|
+
skipped += 1;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
emit({ event: 'result', name: t.name, passed: false, duration, error: (e && e.message) || String(e) });
|
|
329
|
+
failed += 1;
|
|
330
|
+
if (stopOnFail) {
|
|
331
|
+
const rem = tests.length - i - 1;
|
|
332
|
+
if (rem > 0) { emit({ event: 'suite-stopped', name: suiteName, remaining: rem }); skipped += rem; }
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
emit({ event: 'suite-end', name: suiteName, passed, failed, skipped });
|
|
339
|
+
})();
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function handleConsoleLine(text, counts) {
|
|
346
|
+
let evt;
|
|
347
|
+
try { evt = JSON.parse(text.slice('__BXM_TEST__'.length)); } catch (_) { return; }
|
|
348
|
+
if (evt.event === 'result') {
|
|
349
|
+
if (evt.passed) {
|
|
350
|
+
console.log(chalk.green(` ✓ ${evt.name}`) + chalk.gray(` (${evt.duration}ms)`));
|
|
351
|
+
counts.passed += 1;
|
|
352
|
+
} else {
|
|
353
|
+
console.log(chalk.red(` ✗ ${evt.name}`) + chalk.gray(` (${evt.duration}ms)`));
|
|
354
|
+
if (evt.error) console.log(chalk.red(` ${evt.error}`));
|
|
355
|
+
counts.failed += 1;
|
|
356
|
+
}
|
|
357
|
+
} else if (evt.event === 'skip') {
|
|
358
|
+
console.log(chalk.yellow(` ○ ${evt.name}`) + chalk.gray(` (skipped: ${evt.reason})`));
|
|
359
|
+
counts.skipped += 1;
|
|
360
|
+
} else if (evt.event === 'suite-stopped') {
|
|
361
|
+
console.log(chalk.yellow(` Skipping ${evt.remaining} remaining test(s) in suite`));
|
|
362
|
+
} else if (evt.event === 'suite-end' || evt.event === 'suite-start') {
|
|
363
|
+
// No-op — suite framing already printed by the parent before evaluate().
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function waitForTarget(browser, predicate, timeoutMs) {
|
|
368
|
+
const found = browser.targets().find(predicate);
|
|
369
|
+
if (found) return found;
|
|
370
|
+
return new Promise((resolve) => {
|
|
371
|
+
const done = (t) => { browser.off('targetcreated', handle); resolve(t); };
|
|
372
|
+
const handle = (t) => { if (predicate(t)) done(t); };
|
|
373
|
+
browser.on('targetcreated', handle);
|
|
374
|
+
setTimeout(() => done(null), timeoutMs);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Extract the body of a function as a string. Handles arrow / async arrow /
|
|
379
|
+
// named function / async named function forms. Used to ship test bodies into
|
|
380
|
+
// the SW/tab via Runtime.evaluate.
|
|
381
|
+
function extractFnBody(fn) {
|
|
382
|
+
if (typeof fn !== 'function') return 'throw new Error("test has no run() function");';
|
|
383
|
+
const src = fn.toString();
|
|
384
|
+
// Arrow with block body
|
|
385
|
+
let m = src.match(/^\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{([\s\S]*)\}\s*$/);
|
|
386
|
+
if (m) return m[1];
|
|
387
|
+
// Arrow with expression body
|
|
388
|
+
m = src.match(/^\s*(?:async\s+)?\([^)]*\)\s*=>\s*([\s\S]+)$/);
|
|
389
|
+
if (m) return `return ${m[1].trim()};`;
|
|
390
|
+
// Named / anonymous function
|
|
391
|
+
m = src.match(/^\s*(?:async\s+)?function\s*[a-zA-Z0-9_]*\s*\([^)]*\)\s*\{([\s\S]*)\}\s*$/);
|
|
392
|
+
if (m) return m[1];
|
|
393
|
+
// Method shorthand
|
|
394
|
+
m = src.match(/^[^(]*\([^)]*\)\s*\{([\s\S]*)\}\s*$/);
|
|
395
|
+
if (m) return m[1];
|
|
396
|
+
return `return (${src}).call(null, ctx);`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
module.exports = { runChromiumTests };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Background-layer test for chrome.runtime.onMessage / sendMessage. The harness
|
|
2
|
+
// SW (src/test/harness/extension/background.js) ships a ping handler that
|
|
3
|
+
// returns { pong: true, ts }. This test verifies the round-trip works inside
|
|
4
|
+
// the SW context, which is the same primitive BXM consumers use for
|
|
5
|
+
// popup ↔ background messaging.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
type: 'suite',
|
|
9
|
+
layer: 'background',
|
|
10
|
+
description: 'background SW — runtime.sendMessage round-trip',
|
|
11
|
+
tests: [
|
|
12
|
+
{
|
|
13
|
+
name: 'chrome.runtime.sendMessage works (SW → SW self-message)',
|
|
14
|
+
run: async (ctx) => {
|
|
15
|
+
// A SW can't sendMessage to itself directly — Chrome treats own-extension
|
|
16
|
+
// messages from background to background as no-op (no listener context).
|
|
17
|
+
// So this test just verifies the API surface exists + onMessage was
|
|
18
|
+
// registered by the harness without throwing.
|
|
19
|
+
ctx.expect(typeof chrome.runtime.sendMessage).toBe('function');
|
|
20
|
+
ctx.expect(typeof chrome.runtime.onMessage).toBe('object');
|
|
21
|
+
ctx.expect(typeof chrome.runtime.onMessage.addListener).toBe('function');
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'a new listener can be added + removed',
|
|
26
|
+
run: async (ctx) => {
|
|
27
|
+
const handler = () => {};
|
|
28
|
+
chrome.runtime.onMessage.addListener(handler);
|
|
29
|
+
ctx.expect(chrome.runtime.onMessage.hasListener(handler)).toBe(true);
|
|
30
|
+
chrome.runtime.onMessage.removeListener(handler);
|
|
31
|
+
ctx.expect(chrome.runtime.onMessage.hasListener(handler)).toBe(false);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'chrome.runtime.getURL produces a valid chrome-extension:// URL',
|
|
36
|
+
run: async (ctx) => {
|
|
37
|
+
const url = chrome.runtime.getURL('popup.html');
|
|
38
|
+
ctx.expect(url).toMatch(/^chrome-extension:\/\/[a-z]{32}\/popup\.html$/);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Background-layer test for chrome.storage.local round-trip. This exercises
|
|
2
|
+
// the storage-permission grant (which has to be declared in the harness
|
|
3
|
+
// manifest) and the actual SW-side storage API that BXM consumers use.
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
type: 'suite',
|
|
7
|
+
layer: 'background',
|
|
8
|
+
description: 'background SW — chrome.storage.local round-trip',
|
|
9
|
+
tests: [
|
|
10
|
+
{
|
|
11
|
+
name: 'set returns without throwing',
|
|
12
|
+
run: async (ctx) => {
|
|
13
|
+
await chrome.storage.local.set({ bxmTestKey: 'hello' });
|
|
14
|
+
ctx.expect(true).toBe(true); // reached only if set() didn't throw
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'get returns the just-set value',
|
|
19
|
+
run: async (ctx) => {
|
|
20
|
+
const out = await chrome.storage.local.get('bxmTestKey');
|
|
21
|
+
ctx.expect(out.bxmTestKey).toBe('hello');
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'remove clears the key',
|
|
26
|
+
run: async (ctx) => {
|
|
27
|
+
await chrome.storage.local.remove('bxmTestKey');
|
|
28
|
+
const out = await chrome.storage.local.get('bxmTestKey');
|
|
29
|
+
ctx.expect(out.bxmTestKey).toBeUndefined();
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'multiple keys round-trip together',
|
|
34
|
+
run: async (ctx) => {
|
|
35
|
+
await chrome.storage.local.set({ k1: 1, k2: 'two', k3: [1, 2, 3] });
|
|
36
|
+
const out = await chrome.storage.local.get(['k1', 'k2', 'k3']);
|
|
37
|
+
ctx.expect(out.k1).toBe(1);
|
|
38
|
+
ctx.expect(out.k2).toBe('two');
|
|
39
|
+
ctx.expect(out.k3).toEqual([1, 2, 3]);
|
|
40
|
+
await chrome.storage.local.clear();
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Background-layer smoke — verifies the test harness actually executes
|
|
2
|
+
// inside a real MV3 service worker context with `chrome` + `self` globals
|
|
3
|
+
// wired up. If this fails, no other background-layer test can be trusted.
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
type: 'suite',
|
|
7
|
+
layer: 'background',
|
|
8
|
+
description: 'background SW — context smoke',
|
|
9
|
+
tests: [
|
|
10
|
+
{
|
|
11
|
+
name: 'chrome global is defined',
|
|
12
|
+
run: async (ctx) => {
|
|
13
|
+
ctx.expect(typeof chrome).toBe('object');
|
|
14
|
+
ctx.expect(chrome).not.toBeNull();
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'chrome.runtime.id is a non-empty string',
|
|
19
|
+
run: async (ctx) => {
|
|
20
|
+
ctx.expect(typeof chrome.runtime.id).toBe('string');
|
|
21
|
+
ctx.expect(chrome.runtime.id.length).toBeGreaterThan(0);
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'chrome.runtime.getManifest reports manifest_version 3',
|
|
26
|
+
run: async (ctx) => {
|
|
27
|
+
const m = chrome.runtime.getManifest();
|
|
28
|
+
ctx.expect(m.manifest_version).toBe(3);
|
|
29
|
+
ctx.expect(m.name).toBe('BXM Test Harness');
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'BXM_TEST_MODE is wired on globalThis (harness sets it on boot)',
|
|
34
|
+
run: async (ctx) => {
|
|
35
|
+
ctx.expect(globalThis.BXM_TEST_MODE).toBe(true);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'service worker globals (self, fetch, Promise) are present',
|
|
40
|
+
run: async (ctx) => {
|
|
41
|
+
ctx.expect(typeof self).toBe('object');
|
|
42
|
+
ctx.expect(typeof fetch).toBe('function');
|
|
43
|
+
ctx.expect(typeof Promise).toBe('function');
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Boot-layer test — verifies Chromium can load BXM's fixture consumer
|
|
2
|
+
// extension as unpacked, the manifest validates, the SW comes up, and the
|
|
3
|
+
// popup renders end-to-end. This is the integration equivalent to "did the
|
|
4
|
+
// real consumer extension start without throwing".
|
|
5
|
+
//
|
|
6
|
+
// In BXM's own test run, BXM_TEST_BOOT_PROJECT points at the fixture under
|
|
7
|
+
// src/test/fixtures/consumer-extension. In a real consumer's `npx mgr test`
|
|
8
|
+
// run, the env var is unset and boot tests target the consumer's own
|
|
9
|
+
// `<cwd>/dist/`.
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
type: 'group',
|
|
13
|
+
layer: 'boot',
|
|
14
|
+
description: 'fixture consumer — extension loads + boots',
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
description: 'extension has a valid ID and MV3 manifest',
|
|
18
|
+
inspect: async ({ extension, expect }) => {
|
|
19
|
+
expect(extension.id).toMatch(/^[a-z]{32}$/);
|
|
20
|
+
expect(extension.manifest.manifest_version).toBe(3);
|
|
21
|
+
expect(extension.manifest.name).toBe('BXM Fixture Consumer');
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
description: 'manifest exposes a popup URL',
|
|
26
|
+
inspect: async ({ extension, expect }) => {
|
|
27
|
+
expect(extension.popupUrl).toMatch(/^chrome-extension:\/\/[a-z]{32}\/popup\.html$/);
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
description: 'service worker target was discovered',
|
|
32
|
+
inspect: async ({ extension, expect }) => {
|
|
33
|
+
expect(extension.swTarget).not.toBeNull();
|
|
34
|
+
expect(extension.swTarget.type()).toBe('service_worker');
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
description: 'popup.html renders with the expected #main-content element',
|
|
39
|
+
inspect: async ({ extension, page, expect }) => {
|
|
40
|
+
await page.goto(extension.popupUrl, { waitUntil: 'domcontentloaded' });
|
|
41
|
+
const text = await page.$eval('#main-content', (el) => el.textContent);
|
|
42
|
+
expect(text).toContain('Fixture Popup');
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
description: 'background SW responded to a probe message',
|
|
47
|
+
inspect: async ({ extension, page, expect }) => {
|
|
48
|
+
// Open any page that has chrome.runtime access — popup works.
|
|
49
|
+
await page.goto(extension.popupUrl, { waitUntil: 'domcontentloaded' });
|
|
50
|
+
const reply = await page.evaluate(() => chrome.runtime.sendMessage({ type: 'fixture:hello' }));
|
|
51
|
+
expect(reply.ok).toBe(true);
|
|
52
|
+
expect(reply.version).toBe('0.1.0');
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Build-layer tests for lib/affiliatizer.js — exercises the pure map data structure
|
|
2
|
+
// (the bulk of its high-value, currently-untested code). `Affiliatizer.initialize()`
|
|
3
|
+
// touches `window.location` / `chrome.storage` and requires a browser context — not
|
|
4
|
+
// testable at this layer. The regex map IS testable: deterministic, no IO, no globals.
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const Affiliatizer = require(path.join(__dirname, '..', '..', '..', 'lib', 'affiliatizer.js'));
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
type: 'suite',
|
|
12
|
+
layer: 'build',
|
|
13
|
+
description: 'lib/affiliatizer — URL-match map',
|
|
14
|
+
tests: [
|
|
15
|
+
{
|
|
16
|
+
name: 'get() returns an array of entries',
|
|
17
|
+
run: (ctx) => {
|
|
18
|
+
const map = Affiliatizer.get();
|
|
19
|
+
ctx.expect(Array.isArray(map)).toBe(true);
|
|
20
|
+
ctx.expect(map.length).toBeGreaterThan(0);
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'every entry has { id, match, replace }',
|
|
25
|
+
run: (ctx) => {
|
|
26
|
+
for (const entry of Affiliatizer.get()) {
|
|
27
|
+
ctx.expect(typeof entry.id).toBe('string');
|
|
28
|
+
ctx.expect(entry.match).toBeInstanceOf(RegExp);
|
|
29
|
+
ctx.expect(typeof entry.replace).toBe('object');
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'entry ids are unique',
|
|
35
|
+
run: (ctx) => {
|
|
36
|
+
const ids = Affiliatizer.get().map((e) => e.id);
|
|
37
|
+
const set = new Set(ids);
|
|
38
|
+
ctx.expect(set.size).toBe(ids.length);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: 'amazon regex matches amazon.com hostnames',
|
|
43
|
+
run: (ctx) => {
|
|
44
|
+
const amazon = Affiliatizer.get().find((e) => e.id === 'amazon');
|
|
45
|
+
ctx.expect(amazon).toBeDefined();
|
|
46
|
+
ctx.expect(amazon.match.test('www.amazon.com')).toBe(true);
|
|
47
|
+
ctx.expect(amazon.match.test('smile.amazon.com')).toBe(true);
|
|
48
|
+
ctx.expect(amazon.match.test('example.com')).toBe(false);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'every entry that has replace.href has a valid URL',
|
|
53
|
+
run: (ctx) => {
|
|
54
|
+
for (const entry of Affiliatizer.get()) {
|
|
55
|
+
if (!entry.replace.href) continue;
|
|
56
|
+
let threw = false;
|
|
57
|
+
try { new URL(entry.replace.href); } catch (_) { threw = true; }
|
|
58
|
+
ctx.expect(threw).toBe(false);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|