import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { JSDOM } from 'jsdom';
const fixtureDir = dirname(fileURLToPath(import.meta.url));
const output = resolve(fixtureDir, 'dist');
await mkdir(output, { recursive: true });
async function extract(document, marker, filename) {
const markdown = await readFile(resolve(fixtureDir, '../../../docs', document), 'utf8');
assert.equal(markdown.split(marker).length - 1, 1);
const code = markdown.slice(markdown.indexOf(marker) + marker.length)
.match(/^\s*```javascript\n([\s\S]*?)\n```/);
assert.ok(code, 'the marked example must be a JavaScript module');
const path = resolve(output, filename);
await writeFile(path, code[1]);
return path;
}
const counterPath = await extract('readme.md',
'', 'counter.mjs');
const formPath = await extract('forms-and-accessibility.md',
'', 'form.mjs');
const widgetPath = await extract('task-recipes.md',
'', 'widget.mjs');
// Compile the actual TypeScript fences, including rejected consumer options.
const types = await readFile(resolve(fixtureDir, '../../../docs/typescript.md'), 'utf8');
const typeExamples = [...types.matchAll(/```ts\n([\s\S]*?)\n```/g)];
assert.equal(typeExamples.length, 2);
for (const [index, example] of typeExamples.entries()) {
const negative = index === 0 ? `
// @ts-expect-error The documented option is required.
new MessageView();
// @ts-expect-error The documented option is a string.
new MessageView({ message: 42 });
` : '';
await writeFile(resolve(output, `example-${index}.mts`), example[1] + negative);
}
await writeFile(resolve(output, 'tsconfig.json'), JSON.stringify({
compilerOptions: {
target: 'ES2024', module: 'ESNext', moduleResolution: 'Bundler',
strict: true, noEmit: true, skipLibCheck: false, types: [],
lib: ['ES2024', 'DOM', 'DOM.Iterable']
},
include: ['*.mts']
}, null, 2));
execFileSync(process.execPath, [fileURLToPath(import.meta.resolve('typescript/bin/tsc')),
'-p', resolve(output, 'tsconfig.json')], { stdio: 'inherit' });
// Execute the complete testing-guide example in its own process/environment.
const testing = await readFile(resolve(fixtureDir, '../../../docs/testing.md'), 'utf8');
const testCode = testing.match(/```javascript\n([\s\S]*?)\n```/);
assert.ok(testCode);
const testPath = resolve(output, 'counter.test.mjs');
await writeFile(testPath, testCode[1]);
execFileSync(process.execPath, ['--test', testPath], { stdio: 'inherit' });
const dom = new JSDOM('');
globalThis.window = dom.window;
globalThis.document = dom.window.document;
const { Region } = await import('marionette');
const { region: counterRegion } = await import(pathToFileURL(counterPath));
const counter = counterRegion.currentView;
const counterButton = counter.el.querySelector('button');
assert.equal(counterButton.textContent, 'Count: 0');
counterButton.querySelector('span').click();
counterButton.click();
assert.equal(counterButton.textContent, 'Count: 2');
assert.equal(counter.el.querySelector('button'), counterButton);
counter.render();
assert.equal(counter.el.querySelector('button').textContent, 'Count: 2');
const retainedButton = counter.el.querySelector('button');
counterRegion.empty();
retainedButton.click();
assert.equal(counter.count, 2, 'destroyed View no longer handles clicks');
assert.equal(counter.isDestroyed(), true);
assert.equal(document.querySelector('#app').children.length, 0);
counterRegion.destroy();
const { ProfileForm } = await import(pathToFileURL(formPath));
const { WidgetView } = await import(pathToFileURL(widgetPath));
const security = await readFile(resolve(fixtureDir, '../../../docs/security.md'), 'utf8');
const securityExamples = [...security.matchAll(/```javascript\n([\s\S]*?)\n```/g)];
assert.equal(securityExamples.length, 2);
const commentPath = resolve(output, 'comment.mjs');
const urlPath = resolve(output, 'application-url.mjs');
await writeFile(commentPath, securityExamples[0][1]);
await writeFile(urlPath, securityExamples[1][1]);
const { CommentView } = await import(pathToFileURL(commentPath));
const { applicationURL } = await import(pathToFileURL(urlPath));
const hostile = '
';
const comment = new CommentView({ author: hostile, body: hostile }).render();
assert.equal(comment.el.querySelector('img'), null);
assert.equal(comment.el.querySelector('h2').textContent, hostile);
assert.equal(comment.el.querySelector('p').textContent, hostile);
comment.destroy();
assert.equal(applicationURL('/account', 'https://example.test/page'), 'https://example.test/account');
// eslint-disable-next-line no-script-url -- Verify the URL policy rejects executable protocols.
assert.throws(() => applicationURL('javascript:alert(1)', 'https://example.test/page'));
assert.throws(() => applicationURL('//other.test/page', 'https://example.test/page'));
assert.throws(() => applicationURL('data:text/html,hello', 'https://example.test/page'));
const formRegion = new Region({ el: document.querySelector('#form') });
const widgetRegion = new Region({ el: document.querySelector('#widget') });
const calls = [];
try {
const view = new ProfileForm({
displayName: '
',
save(profile, { signal }) {
const pending = { profile, signal, ...Promise.withResolvers() };
calls.push(pending);
return pending.promise;
}
});
formRegion.show(view);
const input = view.el.querySelector('input');
const status = view.el.querySelector('[role="status"]');
const button = view.el.querySelector('button');
assert.equal(input.value, '
');
assert.equal(view.el.querySelector('img'), null);
assert.equal(view.el.querySelector('label').htmlFor, input.id);
assert.equal(input.getAttribute('aria-describedby'), status.id);
const other = new ProfileForm({ displayName: 'Other', save: async() => {} }).render();
assert.notEqual(other.el.querySelector('input').id, input.id);
other.destroy();
input.value = '';
assert.equal(await view.submit(), false);
assert.equal(calls.length, 0);
input.value = 'Unfinished draft';
input.focus();
input.setSelectionRange(3, 8);
const failed = view.submit();
assert.equal(input.readOnly, true);
assert.equal(button.disabled, true);
assert.equal(await view.submit(), false);
assert.equal(calls.length, 1, 'duplicate saves are suppressed');
calls[0].reject(new Error('Private server message'));
assert.equal(await failed, false);
assert.equal(view.el.querySelector('input'), input);
assert.equal(document.activeElement, input);
assert.equal(input.value, 'Unfinished draft');
assert.equal(input.selectionStart, 3);
assert.equal(input.selectionEnd, 8);
assert.equal(input.readOnly, false);
assert.equal(button.disabled, false);
assert.match(status.textContent, /Your changes are still here/);
assert.doesNotMatch(status.textContent, /Private server/);
const saved = view.submit();
assert.deepEqual(calls[1].profile, { displayName: 'Unfinished draft' });
calls[1].resolve();
assert.equal(await saved, true);
assert.equal(status.textContent, 'Saved.');
assert.equal(document.activeElement, input);
assert.equal(view.el.hasAttribute('aria-busy'), false);
input.value = 'Draft reset by explicit render';
const reset = view.submit();
view.render();
assert.equal(calls[2].signal.aborted, true);
calls[2].resolve();
assert.equal(await reset, false);
assert.equal(view.el.querySelector('input').value, 'Unfinished draft');
assert.equal(view.el.hasAttribute('aria-busy'), false);
const currentStatus = view.el.querySelector('[role="status"]');
const stopped = view.submit();
formRegion.empty();
assert.equal(view.isDestroyed(), true);
assert.equal(calls[3].signal.aborted, true);
calls[3].resolve();
assert.equal(await stopped, false, 'ignored abort cannot commit success after destruction');
assert.equal(currentStatus.textContent, 'Saving…');
assert.equal(await view.submit(), false);
const rejecting = new ProfileForm({ displayName: 'Retry', save: view.save });
formRegion.show(rejecting);
const rejected = rejecting.submit();
formRegion.empty();
calls[4].reject(new Error('Late rejection'));
assert.equal(await rejected, false, 'late rejection is handled after destruction');
const handles = [];
const widget = new WidgetView({
createWidget(host) {
assert.equal(host.isConnected, true, 'create only after managed attachment');
const handle = { host, destroyed: 0, destroy() {
assert.equal(host.isConnected, true, 'release before DOM removal');
this.destroyed += 1;
} };
handles.push(handle);
return handle;
}
});
widget.render();
assert.equal(handles.length, 0, 'detached render does not acquire the widget');
widgetRegion.show(widget);
assert.equal(handles.length, 1);
widgetRegion.show(widget);
assert.equal(handles.length, 1, 'reshowing the current view is a no-op');
widget.render();
assert.equal(handles[0].destroyed, 1);
assert.equal(handles.length, 2);
assert.notEqual(handles[0].host, handles[1].host);
assert.equal(widgetRegion.detachView(), widget);
assert.equal(handles[1].destroyed, 1);
assert.equal(widget.isDestroyed(), false);
widgetRegion.show(widget);
assert.equal(handles.length, 3);
widgetRegion.empty();
assert.equal(handles[2].destroyed, 1);
assert.equal(widget.isDestroyed(), true);
widget.destroy();
assert.deepEqual(handles.map(handle => handle.destroyed), [1, 1, 1]);
console.log('Application guides passed: exact TypeScript/test/security snippets, form drafts/focus/errors/cancellation, widget lifecycle.');
} finally {
formRegion.destroy();
widgetRegion.destroy();
dom.window.close();
delete globalThis.window;
delete globalThis.document;
}