Files
cacti/tests/js/sync_js.test.mjs
ccf19b21aa fix(js): align dompurify contract test and vendored purify.js with 3.4.17 pin (#8472)
Commit 8471 bumped dompurify from 3.4.16 to 3.4.17 in package.json but left
the JavaScript contract test and the vendored include/js/purify.js at 3.4.16,
breaking the "JavaScript contracts" CI job on develop (test "DOMPurify is
pinned and generated from the matching npm release").

- Update tests/js/sync_js.test.mjs to expect dompurify 3.4.17 in both the
  package.json pin assertion and the purify.js license-header match.
- Regenerate include/js/purify.js and include/js/purify.js.map from the
  dompurify 3.4.17 npm release via build/sync-js.mjs (npm install postinstall).

All 78 JavaScript contract tests pass locally.

Co-authored-by: TheWitness <copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-23 19:51:15 -04:00

172 lines
6.2 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { test } from 'node:test';
import { assetMap, syncAssets } from '../../build/sync-js.mjs';
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'cacti-js-assets-'));
for (const [source] of Object.entries(assetMap)) {
const path = join(root, source);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, fixtureContent(source));
}
return root;
}
function fixtureContent(source) {
if (source === 'node_modules/screenfull/index.js') {
return `// fixture:${source}\nlet screenfull = {};\nexport default screenfull;\n`;
}
if (source === 'node_modules/pace-js/pace.js') {
return "if (!__hasProp.call(source, key)) continue;\ndocument.body.className.replace(/(pace-done )|/, 'pace-running ');\n";
}
return `fixture:${source}\n`;
}
function assertSynced(root) {
for (const [source, destination] of Object.entries(assetMap)) {
const actual = readFileSync(join(root, destination), 'utf8');
if (source === 'node_modules/screenfull/index.js') {
assert.equal(actual, `// fixture:${source}\nlet screenfull = {};\nwindow.screenfull = screenfull;\n`);
} else if (source === 'node_modules/pace-js/pace.js') {
assert.match(actual, /key === '__proto__'.*key === 'constructor'.*key === 'prototype'/);
assert.match(actual, /replace\(\/\(pace-done \)\//);
assert.doesNotMatch(actual, /pace-done \)\|/);
} else {
assert.equal(actual, fixtureContent(source));
}
}
}
test('syncAssets copies every managed asset and reports each destination', () => {
const root = fixture();
const messages = [];
syncAssets(root, message => messages.push(message));
assertSynced(root);
assert.deepEqual(
messages,
Object.values(assetMap).map(destination => `synced ${destination}`),
);
});
test('pace source changes fail the build instead of silently losing security fixes', () => {
const root = fixture();
writeFileSync(join(root, 'node_modules/pace-js/pace.js'), 'changed upstream source');
assert.throws(() => syncAssets(root, () => {}), /pace source changed/);
});
test('the pager transform handles minified jQuery utilities', () => {
const root = fixture();
writeFileSync(join(root, 'node_modules/tablesorter/dist/js/extras/jquery.tablesorter.pager.min.js'), 'E.isArray(a); E.trim(h[o][n]);');
syncAssets(root, () => {});
assert.equal(readFileSync(join(root, 'include/js/jquery.tablesorter.pager.js'), 'utf8'), "Array.isArray(a); String((h[o][n]) ?? '').trim();");
});
test('unsupported pager utilities fail before shipping a broken asset', () => {
const root = fixture();
writeFileSync(join(root, 'node_modules/tablesorter/dist/js/extras/jquery.tablesorter.pager.min.js'), 'E.isFunction(fn);');
assert.throws(() => syncAssets(root, () => {}), /unsupported jQuery API/);
});
test('the command-line entry point builds a release-tree checkout', () => {
const root = fixture();
const script = resolve('build/sync-js.mjs');
execFileSync(process.execPath, [script], { cwd: root });
assertSynced(root);
});
test('DOMPurify is pinned and generated from the matching npm release', () => {
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const source = 'node_modules/dompurify/dist/purify.js';
assert.equal(packageJson.dependencies.dompurify, '3.4.17');
assert.equal(assetMap[source], 'include/js/purify.js');
assert.match(readFileSync(source, 'utf8'), /DOMPurify 3\.4\.17/);
});
test("screenfull's ESM export is rewritten to a global assignment", () => {
const root = fixture();
syncAssets(root, () => {});
const rewritten = readFileSync(join(root, 'include/js/screenfull.js'), 'utf8');
assert.match(rewritten, /window\.screenfull = screenfull;/);
assert.doesNotMatch(rewritten, /export default/);
});
test('syncAssets fails loudly if screenfull stops shipping the expected ESM export', () => {
const root = fixture();
writeFileSync(join(root, 'node_modules/screenfull/index.js'), 'const screenfull = {};\nexport {screenfull as default};\n');
assert.throws(
() => syncAssets(root, () => {}),
/expected 'export default screenfull;'/,
);
});
test('the tablesorter transform replaces every removed jQuery API with native equivalents', () => {
const root = fixture();
const transformed = [
'node_modules/tablesorter/dist/js/jquery.tablesorter.js',
'node_modules/tablesorter/dist/js/jquery.tablesorter.widgets.js',
];
const source = [
'if ( $.isFunction( fn ) ) { call(); }',
'var trimmed = $.trim( raw );',
"if ( $.type( val ) === 'string' ) { ok(); }",
"if ( $.type( obj ) === 'object' ) { ok(); }",
'var scoped = $.isWindow( scope );',
'var arr = $.isArray( list );',
'var parsed = $.parseJSON( text );',
'if ($.parseJSON) { load(); }',
'return str ? ( str && table.config.ignoreCase ? str.toLocaleLowerCase() : str ).trim() : str;',
'',
].join('\n');
for (const asset of transformed) {
writeFileSync(join(root, asset), source);
}
syncAssets(root, () => {});
for (const asset of transformed) {
const output = readFileSync(join(root, assetMap[asset]), 'utf8');
// every removed jQuery 4 utility is rewritten to its native equivalent
assert.match(output, /typeof fn === 'function'/);
assert.match(output, /String\(\( raw \) \?\? ''\)\.trim\(\)/);
assert.match(output, /typeof val === 'string'/);
assert.match(output, /\$\.isPlainObject\(obj\)/);
assert.match(output, /scope != null && scope === scope\.window/);
assert.match(output, /Array\.isArray\(/);
assert.match(output, /JSON\.parse\(/);
assert.match(output, /if \(window\.JSON && window\.JSON\.parse\)/);
// the CodeQL useless-conditional simplification is applied too
assert.match(output, /\( table\.config\.ignoreCase \?/);
// none of the removed APIs (nor the redundant guard) survive
assert.doesNotMatch(output, /\$\.isFunction/);
assert.doesNotMatch(output, /\$\.trim\(/);
assert.doesNotMatch(output, /\$\.type\(/);
assert.doesNotMatch(output, /\$\.isWindow/);
assert.doesNotMatch(output, /\$\.isArray/);
assert.doesNotMatch(output, /\$\.parseJSON/);
assert.doesNotMatch(output, /str && table\.config\.ignoreCase/);
}
});