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,407 @@
|
|
|
1
|
+
// Test runner — discovers + runs suites, reports BXM-style.
|
|
2
|
+
//
|
|
3
|
+
// Discovery: globs for `test/**/*.js` (recursively, excluding directories starting with `_`)
|
|
4
|
+
// in two locations:
|
|
5
|
+
// 1. The framework itself (browser-extension-manager/dist/test/suites/...) — default suites.
|
|
6
|
+
// 2. The consumer project's CWD (./test/...) — consumer suites.
|
|
7
|
+
//
|
|
8
|
+
// Each test file is a CommonJS module that exports a test definition (see ./index.js).
|
|
9
|
+
// Three forms supported:
|
|
10
|
+
//
|
|
11
|
+
// - Standalone: module.exports = { layer, description, run, cleanup, timeout, skip };
|
|
12
|
+
// - Suite: module.exports = { type: 'suite', layer, description, tests: [...], cleanup, stopOnFailure };
|
|
13
|
+
// - Group: module.exports = { type: 'group', layer, description, tests: [...], cleanup };
|
|
14
|
+
// - Array form: module.exports = [ {name, run}, ... ]; // implicit group
|
|
15
|
+
//
|
|
16
|
+
// `tests[]` items are { name, run(ctx), cleanup?, skip?, timeout? }.
|
|
17
|
+
//
|
|
18
|
+
// Suites stop on first failure (sequential, share state). Groups run all tests regardless.
|
|
19
|
+
//
|
|
20
|
+
// Layers:
|
|
21
|
+
// - 'build' runs in plain Node (this file).
|
|
22
|
+
// - 'background' spawns Chromium via runners/chromium.js, runs in the extension's service worker.
|
|
23
|
+
// - 'view' runs in a Chromium tab loading the harness extension's popup/options/sidepanel HTML.
|
|
24
|
+
// - 'boot' spawns Chromium with the consumer's actual built `dist/` loaded as unpacked.
|
|
25
|
+
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const glob = require('glob').globSync;
|
|
28
|
+
const jetpack = require('fs-jetpack');
|
|
29
|
+
const chalk = require('chalk').default;
|
|
30
|
+
|
|
31
|
+
const expect = require('./assert.js');
|
|
32
|
+
|
|
33
|
+
// Chromium / boot runners are lazy-loaded so a missing puppeteer doesn't prevent build-layer
|
|
34
|
+
// tests from running. The dispatch points below require() them only when those layers exist.
|
|
35
|
+
|
|
36
|
+
class SkipError extends Error {
|
|
37
|
+
constructor(reason) { super(reason); this.name = 'SkipError'; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function run(options = {}) {
|
|
41
|
+
options.layer = options.layer || 'all';
|
|
42
|
+
options.filter = options.filter || null;
|
|
43
|
+
options.reporter = options.reporter || 'pretty';
|
|
44
|
+
|
|
45
|
+
const startTime = Date.now();
|
|
46
|
+
|
|
47
|
+
const sources = discoverTestFiles();
|
|
48
|
+
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(chalk.bold(' Browser Extension Manager Tests'));
|
|
51
|
+
|
|
52
|
+
const results = { passed: 0, failed: 0, skipped: 0, tests: [] };
|
|
53
|
+
|
|
54
|
+
if (sources.framework.length > 0) {
|
|
55
|
+
console.log('');
|
|
56
|
+
console.log(chalk.bold(' Framework Tests'));
|
|
57
|
+
await runSource(sources.framework, 'framework', options, results);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (sources.project.length > 0) {
|
|
61
|
+
console.log('');
|
|
62
|
+
console.log(chalk.bold(' Project Tests'));
|
|
63
|
+
await runSource(sources.project, 'project', options, results);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (sources.framework.length === 0 && sources.project.length === 0) {
|
|
67
|
+
console.log(chalk.gray(' No test files found.'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reportResults(results, Date.now() - startTime);
|
|
71
|
+
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function runSource(files, source, options, results) {
|
|
76
|
+
// Partition by layer (peek at module.exports without invoking run functions).
|
|
77
|
+
const byLayer = { build: [], background: [], view: [], boot: [] };
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const layer = peekLayer(file) || 'build';
|
|
80
|
+
if (byLayer[layer]) byLayer[layer].push(file);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Build layer — run inline.
|
|
84
|
+
if ((options.layer === 'all' || options.layer === 'build') && byLayer.build.length > 0) {
|
|
85
|
+
for (const file of byLayer.build) {
|
|
86
|
+
await runBuildFile(file, source, options, results);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Background + view share one Chromium instance — background suites first, then view
|
|
91
|
+
// suites in tabs against the harness extension.
|
|
92
|
+
const wantsBackground = (options.layer === 'all' || options.layer === 'background') && byLayer.background.length > 0;
|
|
93
|
+
const wantsView = (options.layer === 'all' || options.layer === 'view') && byLayer.view.length > 0;
|
|
94
|
+
|
|
95
|
+
if (wantsBackground || wantsView) {
|
|
96
|
+
let runChromiumTests;
|
|
97
|
+
try {
|
|
98
|
+
({ runChromiumTests } = require('./runners/chromium.js'));
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.log(chalk.yellow(` ○ background + view tests skipped (chromium runner not available: ${e.message})`));
|
|
101
|
+
const skipCount = (wantsBackground ? byLayer.background.length : 0)
|
|
102
|
+
+ (wantsView ? byLayer.view.length : 0);
|
|
103
|
+
results.skipped += skipCount;
|
|
104
|
+
}
|
|
105
|
+
if (runChromiumTests) {
|
|
106
|
+
const projectRoot = process.cwd();
|
|
107
|
+
const counts = await runChromiumTests({
|
|
108
|
+
backgroundSuiteFiles: wantsBackground ? byLayer.background : [],
|
|
109
|
+
viewSuiteFiles: wantsView ? byLayer.view : [],
|
|
110
|
+
filter: options.filter,
|
|
111
|
+
projectRoot,
|
|
112
|
+
bxmDistRoot: path.resolve(__dirname, '..'),
|
|
113
|
+
});
|
|
114
|
+
results.passed += counts.passed;
|
|
115
|
+
results.failed += counts.failed;
|
|
116
|
+
results.skipped += counts.skipped;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Boot layer — spawn Chromium with the consumer's actual built `dist/` and inspect.
|
|
121
|
+
if ((options.layer === 'all' || options.layer === 'boot') && byLayer.boot.length > 0) {
|
|
122
|
+
await runBootLayer(byLayer.boot, source, options, results);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function runBootLayer(files, source, options, results) {
|
|
127
|
+
// Aggregate every boot test (whether standalone or inside a suite) into one flat list.
|
|
128
|
+
// The boot harness runs them sequentially in a single Chromium process to keep startup
|
|
129
|
+
// cost amortized. State doesn't carry across boot tests — each runs against a single
|
|
130
|
+
// shared `extension` from the consumer's actual built `dist/`.
|
|
131
|
+
const tests = [];
|
|
132
|
+
|
|
133
|
+
for (const file of files) {
|
|
134
|
+
let mod;
|
|
135
|
+
try {
|
|
136
|
+
delete require.cache[require.resolve(file)];
|
|
137
|
+
mod = require(file);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
const rel = relativizePath(file, source);
|
|
140
|
+
console.log(chalk.red(` ✗ ${rel}`));
|
|
141
|
+
console.log(chalk.red(` Failed to load: ${e.message}`));
|
|
142
|
+
results.failed += 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(mod)) mod = { type: 'group', tests: mod };
|
|
147
|
+
if (Array.isArray(mod.tests)) {/* multi-test */ }
|
|
148
|
+
else if (typeof mod.inspect === 'function') mod = { tests: [mod] };
|
|
149
|
+
|
|
150
|
+
const baseDescription = mod.description || relativizePath(file, source);
|
|
151
|
+
|
|
152
|
+
for (const t of (mod.tests || [])) {
|
|
153
|
+
if (typeof t.inspect !== 'function') continue;
|
|
154
|
+
if (options.filter && !(t.description || baseDescription).includes(options.filter)) continue;
|
|
155
|
+
tests.push({
|
|
156
|
+
description: t.description || baseDescription,
|
|
157
|
+
timeout: t.timeout || mod.timeout || 20000,
|
|
158
|
+
inspect: t.inspect,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (tests.length === 0) return;
|
|
164
|
+
|
|
165
|
+
let runBootTests;
|
|
166
|
+
try {
|
|
167
|
+
({ runBootTests } = require('./runners/boot.js'));
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.log(chalk.yellow(` ○ boot tests skipped (boot runner not available: ${e.message})`));
|
|
170
|
+
results.skipped += tests.length;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
console.log(chalk.cyan(' ⤷ boot tests (consumer dist/)'));
|
|
175
|
+
|
|
176
|
+
const projectRoot = process.cwd();
|
|
177
|
+
const counts = await runBootTests({
|
|
178
|
+
tests,
|
|
179
|
+
projectRoot,
|
|
180
|
+
bxmDistRoot: path.resolve(__dirname, '..'),
|
|
181
|
+
});
|
|
182
|
+
results.passed += counts.passed;
|
|
183
|
+
results.failed += counts.failed;
|
|
184
|
+
results.skipped += counts.skipped;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function peekLayer(file) {
|
|
188
|
+
try {
|
|
189
|
+
delete require.cache[require.resolve(file)];
|
|
190
|
+
const mod = require(file);
|
|
191
|
+
if (Array.isArray(mod)) return 'build';
|
|
192
|
+
return mod.layer || 'build';
|
|
193
|
+
} catch (e) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runBuildFile(file, source, options, results) {
|
|
199
|
+
let mod;
|
|
200
|
+
try {
|
|
201
|
+
delete require.cache[require.resolve(file)];
|
|
202
|
+
mod = require(file);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
const rel = relativizePath(file, source);
|
|
205
|
+
console.log(chalk.red(` ✗ ${rel}`));
|
|
206
|
+
console.log(chalk.red(` Failed to load: ${e.message}`));
|
|
207
|
+
results.failed += 1;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Array.isArray(mod)) {
|
|
212
|
+
mod = { type: 'group', tests: mod };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const rel = relativizePath(file, source);
|
|
216
|
+
|
|
217
|
+
if (mod.skip) {
|
|
218
|
+
const reason = typeof mod.skip === 'string' ? mod.skip : '';
|
|
219
|
+
console.log(chalk.yellow(` ○ ${mod.description || rel}`) + chalk.gray(` (skipped${reason ? ': ' + reason : ''})`));
|
|
220
|
+
const count = Array.isArray(mod.tests) ? mod.tests.length : 1;
|
|
221
|
+
results.skipped += count;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (mod.type === 'suite' || mod.type === 'group' || Array.isArray(mod.tests)) {
|
|
226
|
+
await runSuite(mod, rel, options, results);
|
|
227
|
+
} else {
|
|
228
|
+
await runStandalone(mod, rel, options, results);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function runSuite(suite, rel, options, results) {
|
|
233
|
+
const description = suite.description || rel;
|
|
234
|
+
const isGroup = suite.type === 'group';
|
|
235
|
+
const stopOnFailure = !isGroup && suite.stopOnFailure !== false;
|
|
236
|
+
const tests = suite.tests || [];
|
|
237
|
+
|
|
238
|
+
console.log(chalk.cyan(` ⤷ ${description}`));
|
|
239
|
+
|
|
240
|
+
const state = {};
|
|
241
|
+
|
|
242
|
+
for (let i = 0; i < tests.length; i += 1) {
|
|
243
|
+
const t = tests[i];
|
|
244
|
+
const name = t.name || `step-${i + 1}`;
|
|
245
|
+
|
|
246
|
+
if (options.filter && !name.includes(options.filter) && !description.includes(options.filter)) continue;
|
|
247
|
+
|
|
248
|
+
if (t.skip) {
|
|
249
|
+
const reason = typeof t.skip === 'string' ? t.skip : '';
|
|
250
|
+
console.log(chalk.yellow(` ○ ${name}`) + chalk.gray(` (skipped${reason ? ': ' + reason : ''})`));
|
|
251
|
+
results.skipped += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const ctx = createContext({ state, layer: suite.layer || 'build' });
|
|
256
|
+
const timeout = t.timeout || suite.timeout || 30000;
|
|
257
|
+
|
|
258
|
+
const start = Date.now();
|
|
259
|
+
try {
|
|
260
|
+
await Promise.race([
|
|
261
|
+
Promise.resolve(t.run(ctx)),
|
|
262
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Test timeout')), timeout)),
|
|
263
|
+
]);
|
|
264
|
+
const duration = Date.now() - start;
|
|
265
|
+
console.log(chalk.green(` ✓ ${name}`) + chalk.gray(` (${duration}ms)`));
|
|
266
|
+
results.passed += 1;
|
|
267
|
+
|
|
268
|
+
if (t.cleanup) {
|
|
269
|
+
try { await t.cleanup(ctx); } catch (e) {
|
|
270
|
+
console.log(chalk.yellow(` ⚠ Cleanup failed: ${e.message}`));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (e) {
|
|
274
|
+
const duration = Date.now() - start;
|
|
275
|
+
if (e.name === 'SkipError') {
|
|
276
|
+
console.log(chalk.yellow(` ○ ${name}`) + chalk.gray(` (skipped: ${e.message})`));
|
|
277
|
+
results.skipped += 1;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
console.log(chalk.red(` ✗ ${name}`) + chalk.gray(` (${duration}ms)`));
|
|
281
|
+
console.log(chalk.red(` ${e.message || e}`));
|
|
282
|
+
results.failed += 1;
|
|
283
|
+
|
|
284
|
+
if (stopOnFailure) {
|
|
285
|
+
const remaining = tests.length - i - 1;
|
|
286
|
+
if (remaining > 0) {
|
|
287
|
+
console.log(chalk.yellow(` Skipping ${remaining} remaining test(s) in suite`));
|
|
288
|
+
results.skipped += remaining;
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (suite.cleanup) {
|
|
296
|
+
try {
|
|
297
|
+
const ctx = createContext({ state, layer: suite.layer || 'build' });
|
|
298
|
+
await suite.cleanup(ctx);
|
|
299
|
+
} catch (e) {
|
|
300
|
+
console.log(chalk.yellow(` ⚠ Suite cleanup failed: ${e.message}`));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function runStandalone(mod, rel, options, results) {
|
|
306
|
+
const description = mod.description || rel;
|
|
307
|
+
if (options.filter && !description.includes(options.filter)) return;
|
|
308
|
+
|
|
309
|
+
const ctx = createContext({ state: {}, layer: mod.layer || 'build' });
|
|
310
|
+
const timeout = mod.timeout || 30000;
|
|
311
|
+
|
|
312
|
+
const start = Date.now();
|
|
313
|
+
try {
|
|
314
|
+
await Promise.race([
|
|
315
|
+
Promise.resolve(mod.run(ctx)),
|
|
316
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Test timeout')), timeout)),
|
|
317
|
+
]);
|
|
318
|
+
const duration = Date.now() - start;
|
|
319
|
+
console.log(chalk.green(` ✓ ${description}`) + chalk.gray(` (${duration}ms)`));
|
|
320
|
+
results.passed += 1;
|
|
321
|
+
|
|
322
|
+
if (mod.cleanup) {
|
|
323
|
+
try { await mod.cleanup(ctx); } catch (e) {
|
|
324
|
+
console.log(chalk.yellow(` ⚠ Cleanup failed: ${e.message}`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
const duration = Date.now() - start;
|
|
329
|
+
if (e.name === 'SkipError') {
|
|
330
|
+
console.log(chalk.yellow(` ○ ${description}`) + chalk.gray(` (skipped: ${e.message})`));
|
|
331
|
+
results.skipped += 1;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
console.log(chalk.red(` ✗ ${description}`) + chalk.gray(` (${duration}ms)`));
|
|
335
|
+
console.log(chalk.red(` ${e.message || e}`));
|
|
336
|
+
results.failed += 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function createContext({ state, layer }) {
|
|
341
|
+
return {
|
|
342
|
+
expect,
|
|
343
|
+
state,
|
|
344
|
+
layer,
|
|
345
|
+
skip(reason) { throw new SkipError(reason || 'skipped at runtime'); },
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function reportResults(results, durationMs) {
|
|
350
|
+
const total = results.passed + results.failed + results.skipped;
|
|
351
|
+
console.log('');
|
|
352
|
+
console.log(' ' + chalk.bold('Results'));
|
|
353
|
+
console.log(` ${chalk.green(`${results.passed} passing`)}`);
|
|
354
|
+
if (results.failed > 0) console.log(` ${chalk.red(`${results.failed} failing`)}`);
|
|
355
|
+
if (results.skipped > 0) console.log(` ${chalk.yellow(`${results.skipped} skipped`)}`);
|
|
356
|
+
console.log(chalk.gray(`\n Total: ${total} tests in ${durationMs}ms\n`));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function discoverTestFiles() {
|
|
360
|
+
const framework = [];
|
|
361
|
+
const project = [];
|
|
362
|
+
|
|
363
|
+
// Detect whether we're running BXM's own framework self-tests, vs a consumer
|
|
364
|
+
// who installed BXM and is running their own tests. Used below to filter the
|
|
365
|
+
// boot/ layer of framework suites — those target BXM's internal fixture
|
|
366
|
+
// extension, so they only make sense when BXM tests itself.
|
|
367
|
+
const isFrameworkSelfTest = (() => {
|
|
368
|
+
try {
|
|
369
|
+
const cwdPkg = require(path.join(process.cwd(), 'package.json'));
|
|
370
|
+
return cwdPkg.name === 'browser-extension-manager';
|
|
371
|
+
} catch (_) { return false; }
|
|
372
|
+
})();
|
|
373
|
+
|
|
374
|
+
// Framework default suites (relative to this file: dist/test/runner.js).
|
|
375
|
+
// For consumers, we exclude boot/ — those suites assert on BXM's own fixture
|
|
376
|
+
// extension (BXM Fixture Consumer, #main-content, fixture:hello message) and
|
|
377
|
+
// would fail noisily when run against a real consumer's packaged extension.
|
|
378
|
+
// Consumers write their own boot tests under <cwd>/test/boot/.
|
|
379
|
+
const frameworkSuitesDir = path.join(__dirname, 'suites');
|
|
380
|
+
if (jetpack.exists(frameworkSuitesDir)) {
|
|
381
|
+
const ignore = ['_**'];
|
|
382
|
+
if (!isFrameworkSelfTest) ignore.push('boot/**');
|
|
383
|
+
glob('**/*.js', { cwd: frameworkSuitesDir, ignore }).sort().forEach((rel) => {
|
|
384
|
+
framework.push(path.join(frameworkSuitesDir, rel));
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Consumer project suites — CWD/test/**/*.js. Skip when running from inside the
|
|
389
|
+
// framework's own dist tree (where consumer-tests-dir === framework-tests-parent).
|
|
390
|
+
const projectTestsDir = path.join(process.cwd(), 'test');
|
|
391
|
+
if (jetpack.exists(projectTestsDir) && projectTestsDir !== path.dirname(frameworkSuitesDir)) {
|
|
392
|
+
glob('**/*.js', { cwd: projectTestsDir, ignore: ['_**'] }).sort().forEach((rel) => {
|
|
393
|
+
project.push(path.join(projectTestsDir, rel));
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return { framework, project };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function relativizePath(file, source) {
|
|
401
|
+
if (source === 'framework') {
|
|
402
|
+
return path.relative(path.join(__dirname, 'suites'), file);
|
|
403
|
+
}
|
|
404
|
+
return path.relative(path.join(process.cwd(), 'test'), file);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
module.exports = { run, SkipError };
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Boot-runner — spawns Chromium with the consumer's actual built `dist/` loaded
|
|
2
|
+
// as an unpacked extension, runs `inspect` functions against the live extension,
|
|
3
|
+
// then closes cleanly.
|
|
4
|
+
//
|
|
5
|
+
// Differences from runners/chromium.js:
|
|
6
|
+
// - chromium.js spawns the harness extension and tests BXM's framework surface.
|
|
7
|
+
// - boot.js spawns the CONSUMER'S `dist/` (their real production extension) and
|
|
8
|
+
// verifies it boots end-to-end: manifest is valid, SW starts, popup loads, etc.
|
|
9
|
+
//
|
|
10
|
+
// Why both? `background` + `view` layers cover framework / lib code fast. `boot`
|
|
11
|
+
// layer covers integration — does the consumer's actual extension boot with their
|
|
12
|
+
// real manifest + brand config + scaffolds? Replaces shell-level smoke tests.
|
|
13
|
+
//
|
|
14
|
+
// `inspect` functions receive { extension, page, expect, projectRoot } where:
|
|
15
|
+
// extension.id — the loaded extension's chrome-extension://<id>
|
|
16
|
+
// extension.manifest — parsed manifest.json
|
|
17
|
+
// extension.popupUrl — chrome-extension://<id>/<action.default_popup>
|
|
18
|
+
// extension.optionsUrl— chrome-extension://<id>/<options_ui.page>
|
|
19
|
+
// extension.swTarget — Puppeteer ServiceWorker target (may be null)
|
|
20
|
+
// page — Puppeteer Page (fresh per test; popup not auto-loaded)
|
|
21
|
+
// projectRoot — absolute path to the consumer project root
|
|
22
|
+
// expect — same Jest-compatible expect() as build/background/view
|
|
23
|
+
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const chalk = require('chalk').default;
|
|
27
|
+
|
|
28
|
+
async function runBootTests({ tests, projectRoot, bxmDistRoot }) {
|
|
29
|
+
if (tests.length === 0) return { passed: 0, failed: 0, skipped: 0 };
|
|
30
|
+
|
|
31
|
+
let puppeteer;
|
|
32
|
+
try {
|
|
33
|
+
puppeteer = require('puppeteer');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.log(chalk.yellow(` ○ boot tests skipped (puppeteer not installed)`));
|
|
36
|
+
return { passed: 0, failed: 0, skipped: tests.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Locate the Chrome-loadable build of the consumer extension. BXM's gulp
|
|
40
|
+
// pipeline produces multiple outputs:
|
|
41
|
+
// - `dist/` — intermediate (JSON5 manifest, raw bundles)
|
|
42
|
+
// - `packaged/<browser>/raw/` — per-browser, strict JSON manifest, Chrome-loadable
|
|
43
|
+
// - `packaged/<browser>/<name>.zip` — store-upload zip
|
|
44
|
+
//
|
|
45
|
+
// Boot tests run against `packaged/chromium/raw/` because that's the directory
|
|
46
|
+
// a developer would point Chrome's "Load unpacked" at (and what zips for the
|
|
47
|
+
// Web Store). It's the actual production-equivalent surface.
|
|
48
|
+
//
|
|
49
|
+
// Discovery order:
|
|
50
|
+
// 1. BXM_TEST_BOOT_DIR (explicit absolute path) — full override
|
|
51
|
+
// 2. <projectRoot>/packaged/chromium/raw — default for BXM consumers
|
|
52
|
+
// 3. <projectRoot>/dist — fallback for non-standard pipelines
|
|
53
|
+
//
|
|
54
|
+
// BXM's own framework boot tests use a fixture extension under
|
|
55
|
+
// src/test/fixtures/consumer-extension/dist (no `packaged/` step needed — the
|
|
56
|
+
// fixture is already strict JSON), so BXM_TEST_BOOT_PROJECT points there and
|
|
57
|
+
// the discovery falls through to `dist`.
|
|
58
|
+
const effectiveRoot = process.env.BXM_TEST_BOOT_PROJECT
|
|
59
|
+
? path.resolve(process.env.BXM_TEST_BOOT_PROJECT)
|
|
60
|
+
: projectRoot;
|
|
61
|
+
|
|
62
|
+
const candidates = [];
|
|
63
|
+
if (process.env.BXM_TEST_BOOT_DIR) candidates.push(path.resolve(process.env.BXM_TEST_BOOT_DIR));
|
|
64
|
+
candidates.push(path.join(effectiveRoot, 'packaged', 'chromium', 'raw'));
|
|
65
|
+
candidates.push(path.join(effectiveRoot, 'dist'));
|
|
66
|
+
|
|
67
|
+
let consumerDist = null;
|
|
68
|
+
let manifestPath = null;
|
|
69
|
+
for (const dir of candidates) {
|
|
70
|
+
const mp = path.join(dir, 'manifest.json');
|
|
71
|
+
if (fs.existsSync(mp)) {
|
|
72
|
+
consumerDist = dir;
|
|
73
|
+
manifestPath = mp;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!consumerDist) {
|
|
78
|
+
console.log(chalk.yellow(` ○ boot tests skipped (no manifest.json found in any of:`));
|
|
79
|
+
for (const c of candidates) console.log(chalk.yellow(` ${c}`));
|
|
80
|
+
console.log(chalk.yellow(` — run \`npm run build\` first to produce packaged/chromium/raw/)`));
|
|
81
|
+
return { passed: 0, failed: 0, skipped: tests.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Chrome requires manifest.json to be STRICT JSON (no comments, no trailing
|
|
85
|
+
// commas, no single quotes). If we matched a directory but its manifest is
|
|
86
|
+
// still JSON5, the user picked the wrong dir — surface that clearly. This is
|
|
87
|
+
// the difference between BXM's intermediate `dist/` (JSON5) and the packaged
|
|
88
|
+
// `packaged/chromium/raw/` (normalized JSON).
|
|
89
|
+
let manifestRaw;
|
|
90
|
+
try {
|
|
91
|
+
manifestRaw = fs.readFileSync(manifestPath, 'utf8');
|
|
92
|
+
JSON.parse(manifestRaw);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.log(chalk.red(` ✗ boot tests aborted: ${manifestPath} is not strict JSON.`));
|
|
95
|
+
console.log(chalk.gray(` Chrome requires manifest.json to have no comments, no trailing commas, no single quotes.`));
|
|
96
|
+
console.log(chalk.gray(` Parser error: ${e.message}`));
|
|
97
|
+
console.log(chalk.gray(` If you see this, the runner picked an intermediate dist/ output instead of a`));
|
|
98
|
+
console.log(chalk.gray(` packaged/<browser>/raw/ output. Run \`npm run build\` to produce the packaged dir,`));
|
|
99
|
+
console.log(chalk.gray(` or set BXM_TEST_BOOT_DIR to the directory that has strict-JSON manifest.json.`));
|
|
100
|
+
return { passed: 0, failed: tests.length, skipped: 0 };
|
|
101
|
+
}
|
|
102
|
+
if (process.env.BXM_TEST_DEBUG) {
|
|
103
|
+
console.log(chalk.gray(` [boot] loading extension from ${consumerDist}`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const expect = require('../assert.js');
|
|
107
|
+
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
108
|
+
|
|
109
|
+
const browser = await puppeteer.launch({
|
|
110
|
+
headless: 'new',
|
|
111
|
+
args: [
|
|
112
|
+
`--disable-extensions-except=${consumerDist}`,
|
|
113
|
+
`--load-extension=${consumerDist}`,
|
|
114
|
+
'--no-sandbox',
|
|
115
|
+
'--disable-dev-shm-usage',
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Discover the loaded extension. Most consumer extensions have an MV3 SW
|
|
121
|
+
// — wait up to 5s for it. If the consumer extension is service-worker-less
|
|
122
|
+
// (rare in MV3 but allowed for action-only extensions), we still get the
|
|
123
|
+
// chrome-extension://<id> via browser targets and the manifest gives us
|
|
124
|
+
// the popup URL.
|
|
125
|
+
const swTarget = await waitForTarget(browser, (t) => t.type() === 'service_worker' && t.url().startsWith('chrome-extension://'), 5000);
|
|
126
|
+
|
|
127
|
+
let extId;
|
|
128
|
+
let manifest;
|
|
129
|
+
if (swTarget) {
|
|
130
|
+
extId = swTarget.url().split('/')[2];
|
|
131
|
+
} else {
|
|
132
|
+
// No SW — find any chrome-extension target.
|
|
133
|
+
const anyExtTarget = browser.targets().find((t) => t.url().startsWith('chrome-extension://'));
|
|
134
|
+
if (!anyExtTarget) {
|
|
135
|
+
console.log(chalk.red(` ✗ Boot aborted — Chromium loaded but no chrome-extension target appeared.`));
|
|
136
|
+
console.log(chalk.gray(` Likely cause: the extension failed to load. Common reasons:`));
|
|
137
|
+
console.log(chalk.gray(` - manifest.json missing required field (e.g. manifest_version: 3)`));
|
|
138
|
+
console.log(chalk.gray(` - default_locale is set but _locales/<locale>/messages.json is missing`));
|
|
139
|
+
console.log(chalk.gray(` - __MSG_*__ placeholders used without default_locale + _locales/`));
|
|
140
|
+
console.log(chalk.gray(` - referenced files (background.service_worker, content_scripts) don't exist on disk`));
|
|
141
|
+
return { passed: 0, failed: tests.length, skipped: 0 };
|
|
142
|
+
}
|
|
143
|
+
extId = anyExtTarget.url().split('/')[2];
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.log(chalk.red(` ✗ Boot aborted — failed to parse manifest: ${e.message}`));
|
|
149
|
+
return { passed: 0, failed: tests.length, skipped: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const popupRel = (manifest.action && manifest.action.default_popup) || null;
|
|
153
|
+
const optionsRel = (manifest.options_ui && manifest.options_ui.page) || (manifest.options_page || null);
|
|
154
|
+
|
|
155
|
+
const extension = {
|
|
156
|
+
id: extId,
|
|
157
|
+
manifest,
|
|
158
|
+
swTarget,
|
|
159
|
+
popupUrl: popupRel ? `chrome-extension://${extId}/${popupRel}` : null,
|
|
160
|
+
optionsUrl: optionsRel ? `chrome-extension://${extId}/${optionsRel}` : null,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
for (const t of tests) {
|
|
164
|
+
const start = Date.now();
|
|
165
|
+
const page = await browser.newPage();
|
|
166
|
+
try {
|
|
167
|
+
await Promise.race([
|
|
168
|
+
t.inspect({ extension, page, expect, projectRoot: effectiveRoot }),
|
|
169
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Boot test timeout')), t.timeout || 20000)),
|
|
170
|
+
]);
|
|
171
|
+
const duration = Date.now() - start;
|
|
172
|
+
console.log(chalk.green(` ✓ ${t.description}`) + chalk.gray(` (${duration}ms)`));
|
|
173
|
+
counts.passed += 1;
|
|
174
|
+
} catch (e) {
|
|
175
|
+
const duration = Date.now() - start;
|
|
176
|
+
console.log(chalk.red(` ✗ ${t.description}`) + chalk.gray(` (${duration}ms)`));
|
|
177
|
+
console.log(chalk.red(` ${(e && e.message) || String(e)}`));
|
|
178
|
+
counts.failed += 1;
|
|
179
|
+
} finally {
|
|
180
|
+
try { await page.close(); } catch (_) { /* ignore */ }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
try { await browser.close(); } catch (_) { /* ignore */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return counts;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function waitForTarget(browser, predicate, timeoutMs) {
|
|
191
|
+
const found = browser.targets().find(predicate);
|
|
192
|
+
if (found) return found;
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const done = (t) => { browser.off('targetcreated', handle); resolve(t); };
|
|
195
|
+
const handle = (t) => { if (predicate(t)) done(t); };
|
|
196
|
+
browser.on('targetcreated', handle);
|
|
197
|
+
setTimeout(() => done(null), timeoutMs);
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { runBootTests };
|