ultimate-jekyll-manager 1.1.9 → 1.2.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/CHANGELOG.md +40 -0
- package/CLAUDE.md +138 -1775
- package/README.md +49 -20
- package/dist/build.js +3 -0
- package/dist/cli.js +1 -0
- package/dist/commands/test.js +56 -0
- package/dist/defaults/CLAUDE.md +72 -6
- package/dist/gulp/tasks/defaults.js +20 -4
- package/dist/gulp/tasks/distribute.js +29 -26
- package/dist/gulp/tasks/jsonToHtml.js +86 -80
- package/dist/gulp/tasks/minifyHtml.js +55 -51
- package/dist/gulp/tasks/sass.js +7 -6
- package/dist/gulp/tasks/utils/template-transform.js +35 -35
- package/dist/index.js +4 -0
- package/dist/test/assert.js +120 -0
- package/dist/test/fixtures/consumer-site/_site/about.html +13 -0
- package/dist/test/fixtures/consumer-site/_site/assets/css/main.bundle.css +2 -0
- package/dist/test/fixtures/consumer-site/_site/assets/js/main.bundle.js +6 -0
- package/dist/test/fixtures/consumer-site/_site/build.json +11 -0
- package/dist/test/fixtures/consumer-site/_site/index.html +28 -0
- package/dist/test/fixtures/consumer-site/_site/service-worker.js +29 -0
- package/dist/test/fixtures/consumer-site/package.json +6 -0
- package/dist/test/harness/page/index.html +51 -0
- package/dist/test/index.js +63 -0
- package/dist/test/runner.js +402 -0
- package/dist/test/runners/boot.js +109 -0
- package/dist/test/runners/chromium.js +255 -0
- package/dist/test/server.js +127 -0
- package/dist/test/suites/boot/service-worker.test.js +84 -0
- package/dist/test/suites/boot/site-loads.test.js +65 -0
- package/dist/test/suites/build/cli.test.js +49 -0
- package/dist/test/suites/build/collect-text-nodes.test.js +37 -0
- package/dist/test/suites/build/dictionary.test.js +17 -0
- package/dist/test/suites/build/expect.test.js +59 -0
- package/dist/test/suites/build/exports.test.js +32 -0
- package/dist/test/suites/build/logger.test.js +62 -0
- package/dist/test/suites/build/manager.test.js +186 -0
- package/dist/test/suites/build/merge-jekyll-configs.test.js +95 -0
- package/dist/test/suites/build/mode-helpers.test.js +65 -0
- package/dist/test/suites/build/template-transform.test.js +94 -0
- package/dist/test/suites/build/templating-brackets.test.js +46 -0
- package/dist/test/suites/build/validate-yaml.test.js +60 -0
- package/dist/test/suites/page/dom-baseline.test.js +34 -0
- package/dist/test/suites/page/harness-globals.test.js +38 -0
- package/dist/test/suites/page/prerendered-icons.test.js +32 -0
- package/dist/utils/mode-helpers.js +84 -0
- package/docs/_legacy-claude-md.md +1832 -0
- package/docs/cross-context-helpers.md +75 -0
- package/docs/test-boot-layer.md +110 -0
- package/docs/test-framework.md +183 -0
- package/package.json +18 -16
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Chromium-runner — launches Puppeteer, serves the harness HTML page from a
|
|
2
|
+
// tiny embedded HTTP server, runs page-layer suites in the tab via
|
|
3
|
+
// `page.evaluate(payload)`. Mirrors BXM's runners/chromium.js shape but UJM
|
|
4
|
+
// has no extension/SW layer — only a `page` layer.
|
|
5
|
+
//
|
|
6
|
+
// Communication channel: each injected test wraps its events as
|
|
7
|
+
// console.log('__UJM_TEST__' + JSON.stringify(evt))
|
|
8
|
+
// from inside the tab. The runner subscribes to Puppeteer's `page.on('console')`
|
|
9
|
+
// and parses those lines exactly like EM/BXM parse stdout/SW console. Same
|
|
10
|
+
// JSON-line 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 (`window`, `document`, etc.).
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const chalk = require('chalk').default;
|
|
21
|
+
|
|
22
|
+
const { startServer } = require('../server.js');
|
|
23
|
+
|
|
24
|
+
// Inline the source of assert.js so we can build it into the injected harness
|
|
25
|
+
// payload. The runner reads it from disk once at module-load time.
|
|
26
|
+
const ASSERT_SRC = fs.readFileSync(path.join(__dirname, '..', 'assert.js'), 'utf8');
|
|
27
|
+
|
|
28
|
+
async function runChromiumTests({ pageSuiteFiles, filter, projectRoot, ujmDistRoot }) {
|
|
29
|
+
let puppeteer;
|
|
30
|
+
try {
|
|
31
|
+
puppeteer = require('puppeteer');
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.log(chalk.yellow(` ○ page tests skipped (puppeteer not installed)`));
|
|
34
|
+
return { passed: 0, failed: 0, skipped: pageSuiteFiles.length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const harnessDir = path.join(ujmDistRoot, 'test', 'harness', 'page');
|
|
38
|
+
if (!fs.existsSync(path.join(harnessDir, 'index.html'))) {
|
|
39
|
+
console.log(chalk.yellow(` ○ page tests skipped (harness not built at ${harnessDir})`));
|
|
40
|
+
return { passed: 0, failed: 0, skipped: pageSuiteFiles.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const counts = { passed: 0, failed: 0, skipped: 0 };
|
|
44
|
+
|
|
45
|
+
const server = await startServer({ root: harnessDir });
|
|
46
|
+
|
|
47
|
+
const browser = await puppeteer.launch({
|
|
48
|
+
headless: 'new',
|
|
49
|
+
args: [
|
|
50
|
+
'--no-sandbox',
|
|
51
|
+
'--disable-dev-shm-usage',
|
|
52
|
+
],
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
for (const file of pageSuiteFiles) {
|
|
57
|
+
let mod;
|
|
58
|
+
try {
|
|
59
|
+
delete require.cache[require.resolve(file)];
|
|
60
|
+
mod = require(file);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
console.log(chalk.red(` ✗ ${file}: Failed to load: ${e.message}`));
|
|
63
|
+
counts.failed += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(mod)) mod = { type: 'group', tests: mod };
|
|
67
|
+
if (mod.layer !== 'page') continue;
|
|
68
|
+
|
|
69
|
+
const suiteName = mod.description || path.basename(file);
|
|
70
|
+
console.log(chalk.cyan(` ⤷ ${suiteName}`));
|
|
71
|
+
|
|
72
|
+
if (mod.skip) {
|
|
73
|
+
const count = Array.isArray(mod.tests) ? mod.tests.length : 1;
|
|
74
|
+
console.log(chalk.yellow(` ○ ${suiteName}`) + chalk.gray(` (skipped)`));
|
|
75
|
+
counts.skipped += count;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const isSuite = mod.type === 'suite' || mod.type === 'group' || Array.isArray(mod.tests);
|
|
80
|
+
const tests = isSuite ? (mod.tests || []) : [{ name: suiteName, run: mod.run, timeout: mod.timeout }];
|
|
81
|
+
const isGroup = mod.type === 'group';
|
|
82
|
+
const stopOnFailure = !isGroup && isSuite && mod.stopOnFailure !== false;
|
|
83
|
+
|
|
84
|
+
const page = await browser.newPage();
|
|
85
|
+
const consoleHandler = (msg) => {
|
|
86
|
+
const text = msg.text();
|
|
87
|
+
if (text.startsWith('__UJM_TEST__')) {
|
|
88
|
+
handleConsoleLine(text, counts);
|
|
89
|
+
} else if (process.env.UJ_TEST_DEBUG) {
|
|
90
|
+
process.stdout.write(chalk.gray(` [tab:${msg.type()}] ${text}\n`));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
page.on('console', consoleHandler);
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
await page.goto(server.baseUrl + '/', { waitUntil: 'domcontentloaded' });
|
|
97
|
+
const payload = buildSuitePayload({ suiteName, tests, filter, stopOnFailure, timeout: mod.timeout });
|
|
98
|
+
await page.evaluate(payload);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
console.log(chalk.red(` ✗ ${suiteName}: harness threw: ${e.message}`));
|
|
101
|
+
counts.failed += tests.length;
|
|
102
|
+
} finally {
|
|
103
|
+
page.off('console', consoleHandler);
|
|
104
|
+
try { await page.close(); } catch (_) { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
try { await browser.close(); } catch (_) { /* ignore */ }
|
|
109
|
+
try { await server.close(); } catch (_) { /* ignore */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return counts;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Suite payload builder ────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
// Build a single string of JavaScript that, when evaluated inside the tab,
|
|
118
|
+
// runs all `tests` sequentially and emits __UJM_TEST__ events per result.
|
|
119
|
+
//
|
|
120
|
+
// Why string-payload vs function-passing? Puppeteer can pass functions, but
|
|
121
|
+
// `page.evaluate(fn, ...args)` serializes args via JSON (no functions). So
|
|
122
|
+
// every test function body must be string-ified and rebuilt inside the target
|
|
123
|
+
// context. We do that here, baking each body as a literal async-function
|
|
124
|
+
// expression at runner build-time. (Same pattern BXM uses to satisfy MV3 CSP;
|
|
125
|
+
// in plain pages CSP is laxer, but the pattern is portable and avoids any
|
|
126
|
+
// eval/Function-constructor surprises if a consumer adds CSP later.)
|
|
127
|
+
function buildSuitePayload({ suiteName, tests, filter, stopOnFailure, timeout: suiteTimeout }) {
|
|
128
|
+
const inlinedTests = tests
|
|
129
|
+
.filter((t) => !filter || (t.name && t.name.includes(filter)) || suiteName.includes(filter))
|
|
130
|
+
.map((t) => {
|
|
131
|
+
const body = extractFnBody(t.run);
|
|
132
|
+
const skip = t.skip ? JSON.stringify(t.skip) : 'false';
|
|
133
|
+
const tout = t.timeout || suiteTimeout || 30000;
|
|
134
|
+
return ` { name: ${JSON.stringify(t.name || suiteName)}, skip: ${skip}, timeout: ${tout}, fn: async (ctx, expect, state) => {\n${body}\n} },`;
|
|
135
|
+
})
|
|
136
|
+
.join('\n');
|
|
137
|
+
|
|
138
|
+
// assert.js declares `function expect(...) { ... }` at the top level. We strip its
|
|
139
|
+
// `module.exports = expect` line (no `module` in the browser) and keep the function
|
|
140
|
+
// declaration available as the local `expect`.
|
|
141
|
+
return `
|
|
142
|
+
(async function () {
|
|
143
|
+
'use strict';
|
|
144
|
+
${ASSERT_SRC.replace(/module\.exports\s*=\s*expect;?/, '')}
|
|
145
|
+
|
|
146
|
+
function emit(evt) {
|
|
147
|
+
console.log('__UJM_TEST__' + JSON.stringify(evt));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
class SkipError extends Error { constructor(reason) { super(reason); this.name = 'SkipError'; } }
|
|
151
|
+
|
|
152
|
+
const suiteName = ${JSON.stringify(suiteName)};
|
|
153
|
+
const stopOnFail = ${JSON.stringify(!!stopOnFailure)};
|
|
154
|
+
const tests = [
|
|
155
|
+
${inlinedTests}
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
emit({ event: 'suite-start', name: suiteName });
|
|
159
|
+
|
|
160
|
+
const state = {};
|
|
161
|
+
let passed = 0, failed = 0, skipped = 0;
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < tests.length; i++) {
|
|
164
|
+
const t = tests[i];
|
|
165
|
+
if (t.skip) {
|
|
166
|
+
const reason = typeof t.skip === 'string' ? t.skip : 'skipped';
|
|
167
|
+
emit({ event: 'skip', name: suiteName + ' → ' + t.name, reason });
|
|
168
|
+
skipped += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const ctx = {
|
|
173
|
+
expect,
|
|
174
|
+
state,
|
|
175
|
+
layer: 'page',
|
|
176
|
+
skip(reason) { throw new SkipError(reason || 'skipped at runtime'); },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const start = Date.now();
|
|
180
|
+
try {
|
|
181
|
+
await Promise.race([
|
|
182
|
+
t.fn(ctx, expect, state),
|
|
183
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Test timeout')), t.timeout)),
|
|
184
|
+
]);
|
|
185
|
+
const duration = Date.now() - start;
|
|
186
|
+
emit({ event: 'result', name: t.name, passed: true, duration });
|
|
187
|
+
passed += 1;
|
|
188
|
+
} catch (e) {
|
|
189
|
+
const duration = Date.now() - start;
|
|
190
|
+
if (e && e.name === 'SkipError') {
|
|
191
|
+
emit({ event: 'skip', name: suiteName + ' → ' + t.name, reason: e.message });
|
|
192
|
+
skipped += 1;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
emit({ event: 'result', name: t.name, passed: false, duration, error: (e && e.message) || String(e) });
|
|
196
|
+
failed += 1;
|
|
197
|
+
if (stopOnFail) {
|
|
198
|
+
const rem = tests.length - i - 1;
|
|
199
|
+
if (rem > 0) { emit({ event: 'suite-stopped', name: suiteName, remaining: rem }); skipped += rem; }
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
emit({ event: 'suite-end', name: suiteName, passed, failed, skipped });
|
|
206
|
+
})();
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
function handleConsoleLine(text, counts) {
|
|
213
|
+
let evt;
|
|
214
|
+
try { evt = JSON.parse(text.slice('__UJM_TEST__'.length)); } catch (_) { return; }
|
|
215
|
+
if (evt.event === 'result') {
|
|
216
|
+
if (evt.passed) {
|
|
217
|
+
console.log(chalk.green(` ✓ ${evt.name}`) + chalk.gray(` (${evt.duration}ms)`));
|
|
218
|
+
counts.passed += 1;
|
|
219
|
+
} else {
|
|
220
|
+
console.log(chalk.red(` ✗ ${evt.name}`) + chalk.gray(` (${evt.duration}ms)`));
|
|
221
|
+
if (evt.error) console.log(chalk.red(` ${evt.error}`));
|
|
222
|
+
counts.failed += 1;
|
|
223
|
+
}
|
|
224
|
+
} else if (evt.event === 'skip') {
|
|
225
|
+
console.log(chalk.yellow(` ○ ${evt.name}`) + chalk.gray(` (skipped: ${evt.reason})`));
|
|
226
|
+
counts.skipped += 1;
|
|
227
|
+
} else if (evt.event === 'suite-stopped') {
|
|
228
|
+
console.log(chalk.yellow(` Skipping ${evt.remaining} remaining test(s) in suite`));
|
|
229
|
+
} else if (evt.event === 'suite-end' || evt.event === 'suite-start') {
|
|
230
|
+
// No-op — suite framing already printed by the parent before evaluate().
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Extract the body of a function as a string. Handles arrow / async arrow /
|
|
235
|
+
// named function / async named function forms. Used to ship test bodies into
|
|
236
|
+
// the tab via page.evaluate.
|
|
237
|
+
function extractFnBody(fn) {
|
|
238
|
+
if (typeof fn !== 'function') return 'throw new Error("test has no run() function");';
|
|
239
|
+
const src = fn.toString();
|
|
240
|
+
// Arrow with block body
|
|
241
|
+
let m = src.match(/^\s*(?:async\s+)?\([^)]*\)\s*=>\s*\{([\s\S]*)\}\s*$/);
|
|
242
|
+
if (m) return m[1];
|
|
243
|
+
// Arrow with expression body
|
|
244
|
+
m = src.match(/^\s*(?:async\s+)?\([^)]*\)\s*=>\s*([\s\S]+)$/);
|
|
245
|
+
if (m) return `return ${m[1].trim()};`;
|
|
246
|
+
// Named / anonymous function
|
|
247
|
+
m = src.match(/^\s*(?:async\s+)?function\s*[a-zA-Z0-9_]*\s*\([^)]*\)\s*\{([\s\S]*)\}\s*$/);
|
|
248
|
+
if (m) return m[1];
|
|
249
|
+
// Method shorthand
|
|
250
|
+
m = src.match(/^[^(]*\([^)]*\)\s*\{([\s\S]*)\}\s*$/);
|
|
251
|
+
if (m) return m[1];
|
|
252
|
+
return `return (${src}).call(null, ctx);`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { runChromiumTests };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Tiny zero-dep HTTP server used by the test harness to serve a directory
|
|
2
|
+
// (either the harness HTML page for the `page` layer, or the consumer's built
|
|
3
|
+
// `_site/` for the `boot` layer).
|
|
4
|
+
//
|
|
5
|
+
// Why a real HTTP server and not file://?
|
|
6
|
+
// - Service workers DO NOT register from file:// URLs. The boot layer
|
|
7
|
+
// specifically verifies SW registration, so we need a real http origin.
|
|
8
|
+
// - Some Web APIs (cookies, localStorage scoping) behave differently on file://.
|
|
9
|
+
//
|
|
10
|
+
// Why hand-roll instead of using `express`/`serve-static`?
|
|
11
|
+
// - Zero deps, fast startup, no risk of clashing with consumer's pinned
|
|
12
|
+
// versions of express. The functionality we need is ~80 lines.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// const { startServer } = require('./server.js');
|
|
16
|
+
// const server = await startServer({ root: '/path/to/_site' });
|
|
17
|
+
// // ...point Puppeteer at server.baseUrl
|
|
18
|
+
// await server.close();
|
|
19
|
+
|
|
20
|
+
const http = require('http');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
|
|
24
|
+
const MIME = {
|
|
25
|
+
'.html': 'text/html; charset=utf-8',
|
|
26
|
+
'.htm': 'text/html; charset=utf-8',
|
|
27
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
28
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
29
|
+
'.css': 'text/css; charset=utf-8',
|
|
30
|
+
'.json': 'application/json; charset=utf-8',
|
|
31
|
+
'.svg': 'image/svg+xml',
|
|
32
|
+
'.png': 'image/png',
|
|
33
|
+
'.jpg': 'image/jpeg',
|
|
34
|
+
'.jpeg': 'image/jpeg',
|
|
35
|
+
'.gif': 'image/gif',
|
|
36
|
+
'.webp': 'image/webp',
|
|
37
|
+
'.ico': 'image/x-icon',
|
|
38
|
+
'.woff': 'font/woff',
|
|
39
|
+
'.woff2': 'font/woff2',
|
|
40
|
+
'.ttf': 'font/ttf',
|
|
41
|
+
'.otf': 'font/otf',
|
|
42
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
43
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
44
|
+
'.map': 'application/json; charset=utf-8',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function startServer({ root }) {
|
|
48
|
+
if (!root) throw new Error('startServer: root is required');
|
|
49
|
+
const absRoot = path.resolve(root);
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const server = http.createServer((req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
// Strip query string + decode.
|
|
55
|
+
let urlPath = decodeURIComponent(req.url.split('?')[0]);
|
|
56
|
+
// Strip leading slash, drop any `..` traversal.
|
|
57
|
+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
58
|
+
|
|
59
|
+
// Resolve against root; refuse anything that escapes root.
|
|
60
|
+
const filePath = path.normalize(path.join(absRoot, urlPath));
|
|
61
|
+
if (!filePath.startsWith(absRoot)) {
|
|
62
|
+
res.writeHead(403); res.end('Forbidden');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let stat;
|
|
67
|
+
try {
|
|
68
|
+
stat = fs.statSync(filePath);
|
|
69
|
+
} catch (_) {
|
|
70
|
+
// Try .html suffix (Jekyll convention: /about → /about.html).
|
|
71
|
+
try {
|
|
72
|
+
const fallback = filePath + '.html';
|
|
73
|
+
const stat2 = fs.statSync(fallback);
|
|
74
|
+
if (stat2.isFile()) return serveFile(fallback, stat2, res);
|
|
75
|
+
} catch (_) { /* fall through to 404 */ }
|
|
76
|
+
res.writeHead(404); res.end('Not Found');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (stat.isDirectory()) {
|
|
81
|
+
const indexPath = path.join(filePath, 'index.html');
|
|
82
|
+
try {
|
|
83
|
+
const idxStat = fs.statSync(indexPath);
|
|
84
|
+
return serveFile(indexPath, idxStat, res);
|
|
85
|
+
} catch (_) { /* fall through */ }
|
|
86
|
+
res.writeHead(404); res.end('Not Found');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return serveFile(filePath, stat, res);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
res.writeHead(500); res.end('Server error: ' + e.message);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.on('error', reject);
|
|
97
|
+
|
|
98
|
+
// Bind to ephemeral port on loopback.
|
|
99
|
+
server.listen(0, '127.0.0.1', () => {
|
|
100
|
+
const port = server.address().port;
|
|
101
|
+
resolve({
|
|
102
|
+
port,
|
|
103
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
104
|
+
close: () => new Promise((r) => server.close(r)),
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function serveFile(filePath, stat, res) {
|
|
111
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
112
|
+
const mime = MIME[ext] || 'application/octet-stream';
|
|
113
|
+
|
|
114
|
+
res.writeHead(200, {
|
|
115
|
+
'Content-Type': mime,
|
|
116
|
+
'Content-Length': stat.size,
|
|
117
|
+
// Allow service workers to register at the site root regardless of the SW
|
|
118
|
+
// file's location. Matters when consumer SW lives at /service-worker.js
|
|
119
|
+
// but other JS lives in /assets/js/. Without this, registration with
|
|
120
|
+
// `scope: '/'` from /assets/js/sw.js would fail.
|
|
121
|
+
'Service-Worker-Allowed': '/',
|
|
122
|
+
'Cache-Control': 'no-store',
|
|
123
|
+
});
|
|
124
|
+
fs.createReadStream(filePath).pipe(res);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { startServer };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Boot test: service-worker.js registers + activates + cache name matches
|
|
2
|
+
// the brand-id + cache-breaker pattern from UJ_BUILD_JSON. This catches the
|
|
3
|
+
// most common SW regressions:
|
|
4
|
+
// - SW served as text/html (wrong MIME → registration silently fails)
|
|
5
|
+
// - skipWaiting + clients.claim not wired → SW never activates
|
|
6
|
+
// - cache name composition broken → wrong cache lookups in fetch handler
|
|
7
|
+
//
|
|
8
|
+
// We wait for `activated` state explicitly (rather than just `controller`)
|
|
9
|
+
// because registration can resolve before activation completes.
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
layer: 'boot',
|
|
13
|
+
description: 'fixture service worker registers + activates',
|
|
14
|
+
type: 'group',
|
|
15
|
+
tests: [
|
|
16
|
+
{
|
|
17
|
+
description: '/service-worker.js served with javascript content type',
|
|
18
|
+
inspect: async ({ site, page, expect }) => {
|
|
19
|
+
const res = await page.goto(site.baseUrl + '/service-worker.js', { waitUntil: 'domcontentloaded' });
|
|
20
|
+
expect(res.status()).toBe(200);
|
|
21
|
+
const ct = res.headers()['content-type'];
|
|
22
|
+
// Either text/javascript or application/javascript is acceptable.
|
|
23
|
+
expect(/(text|application)\/javascript/.test(ct)).toBe(true);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
description: 'index.html registers SW and reaches activated state',
|
|
28
|
+
timeout: 15000,
|
|
29
|
+
inspect: async ({ site, page, expect }) => {
|
|
30
|
+
await page.goto(site.baseUrl + '/', { waitUntil: 'load' });
|
|
31
|
+
|
|
32
|
+
// Poll for activation. `navigator.serviceWorker.ready` resolves when a
|
|
33
|
+
// worker is registered + non-null, but the worker may still be in
|
|
34
|
+
// `installing`/`activating` for a few ms. Wait up to 5s for activated.
|
|
35
|
+
const result = await page.evaluate(async () => {
|
|
36
|
+
if (!('serviceWorker' in navigator)) return { error: 'no-sw-api' };
|
|
37
|
+
const reg = await navigator.serviceWorker.ready;
|
|
38
|
+
const start = Date.now();
|
|
39
|
+
while (Date.now() - start < 5000) {
|
|
40
|
+
const worker = reg.active || reg.installing || reg.waiting;
|
|
41
|
+
if (worker && worker.state === 'activated') {
|
|
42
|
+
return { scope: reg.scope, state: worker.state };
|
|
43
|
+
}
|
|
44
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
45
|
+
}
|
|
46
|
+
const worker = reg.active || reg.installing || reg.waiting;
|
|
47
|
+
return { scope: reg.scope, state: (worker && worker.state) || 'unknown' };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(result.error).toBeUndefined();
|
|
51
|
+
expect(result.state).toBe('activated');
|
|
52
|
+
expect(result.scope).toMatch(/\/$/);
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
description: 'SW responds to get-cache-name message with brand-id pattern',
|
|
57
|
+
timeout: 15000,
|
|
58
|
+
inspect: async ({ site, page, expect }) => {
|
|
59
|
+
await page.goto(site.baseUrl + '/', { waitUntil: 'load' });
|
|
60
|
+
|
|
61
|
+
// Wait for SW to be activated AND controlling this page.
|
|
62
|
+
await page.evaluate(async () => {
|
|
63
|
+
await navigator.serviceWorker.ready;
|
|
64
|
+
if (!navigator.serviceWorker.controller) {
|
|
65
|
+
await new Promise((resolve) => {
|
|
66
|
+
navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const name = await page.evaluate(async () => {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const channel = new MessageChannel();
|
|
74
|
+
channel.port1.onmessage = (e) => resolve(e.data && e.data.name);
|
|
75
|
+
navigator.serviceWorker.controller.postMessage({ command: 'get-cache-name' }, [channel.port2]);
|
|
76
|
+
setTimeout(() => resolve(null), 5000);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(name).toBe('ujm-fixture-fixture-1');
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Boot test: fixture _site/ loads end-to-end. Asserts:
|
|
2
|
+
// - / serves a 200, has a <title>, has expected content
|
|
3
|
+
// - /about resolves (Jekyll-style suffix fallback)
|
|
4
|
+
// - /build.json is present + parseable + has expected brand
|
|
5
|
+
// - /assets/css/main.bundle.css served as text/css
|
|
6
|
+
// - /assets/js/main.bundle.js loads without throwing
|
|
7
|
+
// - no console.error fires during page load
|
|
8
|
+
//
|
|
9
|
+
// When a consumer (not UJM itself) runs `npx mgr test`, this suite is excluded
|
|
10
|
+
// — it asserts on UJM's fixture site, not the consumer's.
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
layer: 'boot',
|
|
14
|
+
description: 'fixture _site/ renders + serves assets',
|
|
15
|
+
type: 'group',
|
|
16
|
+
tests: [
|
|
17
|
+
{
|
|
18
|
+
description: 'home page renders with title + body content',
|
|
19
|
+
inspect: async ({ site, page, expect }) => {
|
|
20
|
+
const errors = [];
|
|
21
|
+
page.on('pageerror', (e) => errors.push(e.message));
|
|
22
|
+
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
|
|
23
|
+
|
|
24
|
+
const res = await page.goto(site.baseUrl + '/', { waitUntil: 'load' });
|
|
25
|
+
expect(res.status()).toBe(200);
|
|
26
|
+
expect(await page.title()).toBe('UJM Fixture Consumer');
|
|
27
|
+
|
|
28
|
+
const h1 = await page.$eval('h1', (el) => el.textContent);
|
|
29
|
+
expect(h1).toBe('UJM Fixture Consumer');
|
|
30
|
+
|
|
31
|
+
// Wait a tick so any console.error from late-resolving promises arrives.
|
|
32
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
33
|
+
expect(errors).toEqual([]);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
description: '/about resolves via Jekyll-style .html fallback',
|
|
38
|
+
inspect: async ({ site, page, expect }) => {
|
|
39
|
+
const res = await page.goto(site.baseUrl + '/about', { waitUntil: 'domcontentloaded' });
|
|
40
|
+
expect(res.status()).toBe(200);
|
|
41
|
+
const title = await page.title();
|
|
42
|
+
expect(title).toContain('About');
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
description: 'build.json is served with brand metadata',
|
|
47
|
+
inspect: async ({ site, page, expect }) => {
|
|
48
|
+
const res = await page.goto(site.baseUrl + '/build.json', { waitUntil: 'domcontentloaded' });
|
|
49
|
+
expect(res.status()).toBe(200);
|
|
50
|
+
const body = await page.evaluate(() => document.body.innerText);
|
|
51
|
+
const data = JSON.parse(body);
|
|
52
|
+
expect(data.config.brand.id).toBe('ujm-fixture');
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
description: 'CSS bundle served with text/css content type',
|
|
57
|
+
inspect: async ({ site, page, expect }) => {
|
|
58
|
+
const res = await page.goto(site.baseUrl + '/assets/css/main.bundle.css', { waitUntil: 'domcontentloaded' });
|
|
59
|
+
expect(res.status()).toBe(200);
|
|
60
|
+
const ct = res.headers()['content-type'];
|
|
61
|
+
expect(ct).toContain('text/css');
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// CLI alias resolution — verifies src/cli.js routes positional commands and
|
|
2
|
+
// flag aliases to the right command file under src/commands/.
|
|
3
|
+
//
|
|
4
|
+
// We don't actually invoke commands (those have side-effects: subprocesses,
|
|
5
|
+
// network, disk). Instead we reach inside cli.js's resolveCommand by
|
|
6
|
+
// instantiating Main and seeing which command file it tries to load —
|
|
7
|
+
// catching the error message which contains the resolved command name.
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
layer: 'build',
|
|
11
|
+
description: 'CLI alias resolution',
|
|
12
|
+
type: 'group',
|
|
13
|
+
tests: [
|
|
14
|
+
{
|
|
15
|
+
name: 'cli.js exports a Main class',
|
|
16
|
+
run: async (ctx) => {
|
|
17
|
+
const Main = require('../../../cli.js');
|
|
18
|
+
ctx.expect(typeof Main).toBe('function');
|
|
19
|
+
const main = new Main();
|
|
20
|
+
ctx.expect(typeof main.process).toBe('function');
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'all expected commands exist on disk',
|
|
25
|
+
run: async (ctx) => {
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const commandsDir = path.join(__dirname, '..', '..', '..', 'commands');
|
|
29
|
+
const expected = ['clean', 'cloudflare-purge', 'install', 'setup', 'version', 'deploy', 'audit', 'translation', 'test'];
|
|
30
|
+
for (const cmd of expected) {
|
|
31
|
+
const file = path.join(commandsDir, `${cmd}.js`);
|
|
32
|
+
ctx.expect(fs.existsSync(file)).toBe(true);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'each command module exports an async function',
|
|
38
|
+
run: async (ctx) => {
|
|
39
|
+
const path = require('path');
|
|
40
|
+
const commandsDir = path.join(__dirname, '..', '..', '..', 'commands');
|
|
41
|
+
const cmds = ['clean', 'version', 'test'];
|
|
42
|
+
for (const cmd of cmds) {
|
|
43
|
+
const mod = require(path.join(commandsDir, `${cmd}.js`));
|
|
44
|
+
ctx.expect(typeof mod).toBe('function');
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// collectTextNodes from src/gulp/tasks/utils/collectTextNodes.js. Used by
|
|
2
|
+
// audit (spellcheck) and translation (i18n) to walk an HTML document and
|
|
3
|
+
// extract every renderable text node + translatable attribute. If this
|
|
4
|
+
// silently degrades, audit reports zero misspellings and translation
|
|
5
|
+
// produces empty output — both regressions that pass CI but break consumers.
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
layer: 'build',
|
|
9
|
+
description: 'collectTextNodes (utils/collectTextNodes.js)',
|
|
10
|
+
type: 'group',
|
|
11
|
+
tests: [
|
|
12
|
+
{
|
|
13
|
+
name: 'extracts page title',
|
|
14
|
+
run: async (ctx) => {
|
|
15
|
+
const cheerio = require('cheerio');
|
|
16
|
+
const collectTextNodes = require('../../../gulp/tasks/utils/collectTextNodes.js');
|
|
17
|
+
const $ = cheerio.load('<html><head><title>Hello World</title></head><body><p>Body</p></body></html>');
|
|
18
|
+
const nodes = collectTextNodes($);
|
|
19
|
+
const titleNode = nodes.find((n) => n.text === 'Hello World');
|
|
20
|
+
ctx.expect(titleNode).toBeTruthy();
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'skips <script> and <style>',
|
|
25
|
+
run: async (ctx) => {
|
|
26
|
+
const cheerio = require('cheerio');
|
|
27
|
+
const collectTextNodes = require('../../../gulp/tasks/utils/collectTextNodes.js');
|
|
28
|
+
const $ = cheerio.load('<html><body><script>secret_script_text</script><style>secret_css_text</style><p>visible</p></body></html>');
|
|
29
|
+
const nodes = collectTextNodes($);
|
|
30
|
+
const texts = nodes.map((n) => n.text).filter(Boolean);
|
|
31
|
+
ctx.expect(texts.some((t) => t.includes('visible'))).toBe(true);
|
|
32
|
+
ctx.expect(texts.some((t) => t.includes('secret_script_text'))).toBe(false);
|
|
33
|
+
ctx.expect(texts.some((t) => t.includes('secret_css_text'))).toBe(false);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The spellcheck dictionary at src/gulp/tasks/utils/dictionary.js. Pure data
|
|
2
|
+
// array — verify it loads and is non-empty. Acts as a tripwire: if the file
|
|
3
|
+
// is deleted or its export shape changes (e.g. someone wraps it in an object),
|
|
4
|
+
// audit.js spellcheck silently degrades. This test catches that early.
|
|
5
|
+
|
|
6
|
+
module.exports = {
|
|
7
|
+
layer: 'build',
|
|
8
|
+
description: 'spellcheck dictionary (utils/dictionary.js)',
|
|
9
|
+
run: async (ctx) => {
|
|
10
|
+
const dict = require('../../../gulp/tasks/utils/dictionary.js');
|
|
11
|
+
ctx.expect(Array.isArray(dict)).toBe(true);
|
|
12
|
+
ctx.expect(dict.length).toBeGreaterThan(50);
|
|
13
|
+
// Spot-check known entries.
|
|
14
|
+
ctx.expect(dict).toContain('webhook');
|
|
15
|
+
ctx.expect(dict).toContain('api');
|
|
16
|
+
},
|
|
17
|
+
};
|