Alonso Torres 7655f854a4
Improved api test suite (#10533)
* 🐛 Fix plugins api issues

* 🐛 Fix problem with text weight

* 🐛 Fix api problems

*  Updated plugins test suite

* 🐛 Referential integrity tests

*  Add resetOverrides method

* 🐛 Fix plugins components structure poluting
2026-07-07 16:12:12 +02:00

596 lines
17 KiB
TypeScript

import 'plugins-styles/lib/styles.css';
import './ui.css';
import type { PluginToUIMessage, UIToPluginMessage } from './model';
import type {
CoverageReport,
TestMeta,
TestResult,
TestStatus,
} from './framework/types';
const root = document.getElementById('app') as HTMLElement;
let tests: TestMeta[] = [];
const results = new Map<string, TestResult>();
const selected = new Set<string>();
const expandedGroups = new Set<string>();
let running = false;
let stopping = false;
let reloading = false;
let statusText = '';
/** Groups tests by their `group`, preserving first-seen order. */
function groupTests(): { name: string; tests: TestMeta[] }[] {
const order: string[] = [];
const byGroup = new Map<string, TestMeta[]>();
for (const test of tests) {
let bucket = byGroup.get(test.group);
if (!bucket) {
bucket = [];
byGroup.set(test.group, bucket);
order.push(test.group);
}
bucket.push(test);
}
return order.map((name) => ({ name, tests: byGroup.get(name)! }));
}
function applyTheme(theme: string | null) {
document.documentElement.setAttribute(
'data-theme',
theme === 'light' ? 'light' : 'dark',
);
}
function sendToPlugin(message: UIToPluginMessage) {
// `'*'` is intentional: the plugin host controls this iframe's parent and the
// exact embedding origin isn't known ahead of time. Standard for Penpot
// plugin iframes; nothing sensitive crosses this channel.
parent.postMessage(message, '*');
}
/**
* Rolls a group's leaf statuses up into a single status for its header dot:
* running if any test is running, otherwise failed if any failed, otherwise
* passed only when every test passed, and pending until then.
*/
function aggregateStatus(statuses: TestStatus[]): TestStatus {
if (statuses.some((s) => s === 'running')) return 'running';
if (statuses.some((s) => s === 'fail')) return 'fail';
if (statuses.length > 0 && statuses.every((s) => s === 'pass')) return 'pass';
return 'pending';
}
function statusLabel(status: TestStatus): string {
switch (status) {
case 'pass':
return 'Passed';
case 'fail':
return 'Failed';
case 'running':
return 'Running…';
default:
return 'Not run';
}
}
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
props: Partial<HTMLElementTagNameMap[K]> = {},
children: (Node | string)[] = [],
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
Object.assign(node, props);
for (const child of children) {
node.append(child);
}
return node;
}
function svgIcon(paths: string[], fill: boolean): SVGSVGElement {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('aria-hidden', 'true');
svg.classList.add('icon');
for (const d of paths) {
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', d);
if (fill) {
path.setAttribute('fill', 'currentColor');
} else {
path.setAttribute('fill', 'none');
path.setAttribute('stroke', 'currentColor');
path.setAttribute('stroke-width', '1.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
}
svg.append(path);
}
return svg;
}
/** A small triangular "play" icon used on run buttons. */
function playIcon(): SVGSVGElement {
return svgIcon(['M4 2.5v11l9-5.5z'], true);
}
/** A spinning ring, shown wherever something is in progress. */
function spinner(): HTMLElement {
return el('span', { className: 'spinner', ariaLabel: 'In progress' });
}
/** A circular-arrow "reload" icon. */
function reloadIcon(): SVGSVGElement {
return svgIcon(['M13 8a5 5 0 1 1-1.46-3.54', 'M13 2.5v3h-3'], false);
}
function render() {
root.replaceChildren(
renderHeader(),
renderToolbar(),
renderList(),
renderCoverage(),
);
}
function renderHeader(): HTMLElement {
const passed = [...results.values()].filter(
(r) => r.status === 'pass',
).length;
const failed = [...results.values()].filter(
(r) => r.status === 'fail',
).length;
const summary = el('p', { className: 'summary' }, [
`${tests.length} tests · ${passed} passed · ${failed} failed`,
]);
if (running) {
summary.append(
' · ',
el('span', { className: 'running-badge' }, [spinner(), 'Running…']),
);
}
return el('header', { className: 'header' }, [
el('h1', { className: 'title', textContent: 'Plugin API Test Suite' }),
summary,
]);
}
function renderToolbar(): HTMLElement {
// While a run is in progress the primary action becomes Stop; otherwise it
// runs all tests.
const primary = running
? el('button', {
textContent: 'Stop',
disabled: stopping,
})
: el('button', {
textContent: 'Run all',
disabled: tests.length === 0,
});
primary.dataset.appearance = 'primary';
if (running) {
primary.dataset.variant = 'destructive';
primary.addEventListener('click', () => stop());
} else {
primary.addEventListener('click', () => run('all'));
}
const runSelected = el('button', {
textContent: 'Run selected',
disabled: running || selected.size === 0,
});
runSelected.dataset.appearance = 'secondary';
runSelected.addEventListener('click', () => run([...selected]));
const reload = el('button', {
className: `icon-button reload${reloading ? ' is-loading' : ''}`,
title: reloading
? 'Reloading tests…'
: 'Reload: fetch and apply edited tests without reopening the plugin',
ariaLabel: 'Reload tests',
disabled: running || reloading,
});
reload.dataset.appearance = 'secondary';
reload.append(reloadIcon());
reload.addEventListener('click', () => reloadTests());
const toolbar = el('div', { className: 'toolbar' }, [
primary,
runSelected,
reload,
]);
if (statusText) {
toolbar.append(
el('span', { className: 'toolbar-status', textContent: statusText }),
);
}
return toolbar;
}
function renderRow(test: TestMeta): HTMLElement {
const result = results.get(test.id);
const status = result?.status ?? 'pending';
const checkbox = el('input', {
type: 'checkbox',
className: 'checkbox-input',
checked: selected.has(test.id),
disabled: running,
});
checkbox.addEventListener('change', () => {
if (checkbox.checked) selected.add(test.id);
else selected.delete(test.id);
render();
});
const runButton = el('button', {
className: 'icon-button run-single',
title: `Run "${test.name}"`,
ariaLabel: `Run "${test.name}"`,
disabled: running,
});
runButton.dataset.appearance = 'secondary';
runButton.append(playIcon());
runButton.addEventListener('click', () => run([test.id]));
const durationCell =
status === 'running'
? el('span', { className: 'test-duration running' }, [
spinner(),
'Running…',
])
: el('span', {
className: 'test-duration',
textContent: result ? `${result.durationMs}ms` : '',
});
const row = el('li', { className: `test-row status-${status}` }, [
el('label', { className: 'test-main' }, [
checkbox,
el('span', {
className: `status-dot dot-${status}`,
title: statusLabel(status),
}),
el('span', { className: 'test-name', textContent: test.name }),
]),
durationCell,
runButton,
]);
if (result?.status === 'fail' && result.error) {
row.append(
el('pre', { className: 'test-error', textContent: result.error }),
);
}
return row;
}
function renderGroupSummary(
name: string,
groupTestList: TestMeta[],
): HTMLElement {
const statuses = groupTestList.map(
(t) => results.get(t.id)?.status ?? 'pending',
);
const passed = statuses.filter((s) => s === 'pass').length;
const failed = statuses.filter((s) => s === 'fail').length;
const total = groupTestList.length;
const aggregate = aggregateStatus(statuses);
// Select-all checkbox for the group (indeterminate when partially selected).
const ids = groupTestList.map((t) => t.id);
const selectedCount = ids.filter((id) => selected.has(id)).length;
const groupCheckbox = el('input', {
type: 'checkbox',
className: 'checkbox-input',
checked: selectedCount === total && total > 0,
disabled: running,
});
groupCheckbox.indeterminate = selectedCount > 0 && selectedCount < total;
// Keep the checkbox from toggling the <details> when clicked.
groupCheckbox.addEventListener('click', (e) => e.stopPropagation());
groupCheckbox.addEventListener('change', () => {
if (groupCheckbox.checked) ids.forEach((id) => selected.add(id));
else ids.forEach((id) => selected.delete(id));
render();
});
const runButton = el('button', {
className: 'icon-button run-group',
title: `Run "${name}"`,
ariaLabel: `Run "${name}"`,
disabled: running,
});
runButton.dataset.appearance = 'secondary';
runButton.append(playIcon());
runButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
run(ids);
});
const counts = el('span', { className: 'group-counts' }, [
el('span', { className: 'count-pass', textContent: `${passed}` }),
el('span', { className: 'count-sep', textContent: ' / ' }),
el('span', { className: 'count-fail', textContent: `${failed}` }),
el('span', {
className: 'count-total',
textContent: ` · ${total} test${total === 1 ? '' : 's'}`,
}),
]);
return el('summary', { className: 'group-summary' }, [
groupCheckbox,
el('span', {
className: `status-dot dot-${aggregate}`,
title: statusLabel(aggregate),
}),
el('span', { className: 'group-name', textContent: name }),
counts,
runButton,
]);
}
function renderList(): HTMLElement {
const container = el('div', { className: 'groups' });
for (const group of groupTests()) {
const details = el('details', { className: 'group' });
// Groups are collapsed by default; remember the ones the user expands.
details.open = expandedGroups.has(group.name);
details.addEventListener('toggle', () => {
if (details.open) expandedGroups.add(group.name);
else expandedGroups.delete(group.name);
});
details.append(renderGroupSummary(group.name, group.tests));
const list = el('ul', { className: 'test-list' });
for (const test of group.tests) {
list.append(renderRow(test));
}
details.append(list);
container.append(details);
}
return container;
}
let lastCoverage: CoverageReport | null = null;
function renderProgressBar(
percent: number,
effectivePercent: number,
): HTMLElement {
const track = el('div', {
className: 'progress-track',
role: 'progressbar',
title: `${percent}% recorded, ${effectivePercent}% effective`,
});
track.setAttribute('aria-valuenow', String(percent));
track.setAttribute('aria-valuemin', '0');
track.setAttribute('aria-valuemax', '100');
// Layered: the static segment (lighter) spans the effective coverage, the
// recorded fill (green) sits on top spanning the recorder-credited coverage.
const staticFill = el('div', { className: 'progress-fill static' });
staticFill.style.width = `${effectivePercent}%`;
const fill = el('div', { className: 'progress-fill' });
fill.style.width = `${percent}%`;
track.append(staticFill, fill);
return track;
}
function renderCoverage(): HTMLElement {
const section = el('div', { className: 'coverage' });
if (!lastCoverage) {
section.append(
el('p', {
className: 'coverage-empty',
textContent: 'API coverage — run tests to measure',
}),
);
return section;
}
const {
covered,
staticallyCovered,
total,
percent,
effectivePercent,
byInterface,
} = lastCoverage;
const valueText =
staticallyCovered > 0
? `${percent}% · ${effectivePercent}% eff. (${covered}+${staticallyCovered}/${total})`
: `${percent}% (${covered}/${total})`;
section.append(
el('div', { className: 'coverage-header' }, [
el('span', {
className: 'coverage-title',
textContent: 'API coverage',
}),
el('span', {
className: 'coverage-value',
textContent: valueText,
}),
]),
renderProgressBar(percent, effectivePercent),
);
const details = el('details', { className: 'coverage-details' });
details.append(el('summary', { textContent: 'Coverage by interface' }));
const list = el('div', { className: 'coverage-body' });
const interfaces = Object.entries(byInterface)
.filter(([, info]) => info.members.length > 0)
.sort(([a], [b]) => a.localeCompare(b));
for (const [iface, info] of interfaces) {
const members = el('div', { className: 'coverage-members' });
// Covered (green) first, then statically covered (blue), then uncovered.
for (const m of info.covered) {
members.append(
el('span', { className: 'coverage-member covered', textContent: m }),
);
}
for (const m of info.staticallyCovered) {
members.append(
el('span', {
className: 'coverage-member static',
textContent: m,
title: 'Exercised behaviourally; not creditable via the proxy',
}),
);
}
for (const m of info.uncovered) {
members.append(
el('span', { className: 'coverage-member uncovered', textContent: m }),
);
}
const ifaceLabel =
info.staticallyCovered.length > 0
? `${iface} (${info.covered.length}+${info.staticallyCovered.length}/${info.members.length})`
: `${iface} (${info.covered.length}/${info.members.length})`;
list.append(
el('div', { className: 'coverage-iface' }, [
el('strong', {
textContent: ifaceLabel,
}),
members,
]),
);
}
details.append(list);
section.append(details);
return section;
}
function run(ids: string[] | 'all') {
if (running) return;
running = true;
stopping = false;
statusText = '';
const targetIds = ids === 'all' ? tests.map((t) => t.id) : ids;
for (const id of targetIds) {
const test = tests.find((t) => t.id === id);
if (test) {
results.set(id, {
id,
name: test.name,
status: 'running',
durationMs: 0,
});
}
}
render();
sendToPlugin({ type: 'run', ids });
}
/**
* Asks the sandbox to stop after the current test. The run keeps going until the
* in-flight test settles, at which point a `runComplete` arrives and clears the
* running state.
*/
function stop() {
if (!running || stopping) return;
stopping = true;
statusText = 'Stopping after the current test…';
render();
sendToPlugin({ type: 'stop' });
}
async function reloadTests() {
if (running || reloading) return;
reloading = true;
statusText = '';
render();
try {
// Fetch the freshly built tests bundle from the dev server (same origin as
// this iframe). `vite build --watch` rebuilds it on every save, so this
// picks up edited tests. The cache-busting query avoids any stale copy.
const response = await fetch(`./tests-bundle.js?t=${Date.now()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tests bundle (${response.status})`);
}
const code = await response.text();
// The sandbox evaluates the bundle and replies with `reloaded` + `tests`.
sendToPlugin({ type: 'reloadTests', code });
} catch (err) {
reloading = false;
statusText = `Reload failed: ${err instanceof Error ? err.message : String(err)}`;
render();
}
}
window.addEventListener('message', (event: MessageEvent<PluginToUIMessage>) => {
const message = event.data;
if (!message || typeof message !== 'object') return;
switch (message.type) {
case 'tests': {
tests = message.tests;
// Drop results/selection for tests that no longer exist after a reload.
const ids = new Set(tests.map((t) => t.id));
for (const id of [...results.keys()]) {
if (!ids.has(id)) results.delete(id);
}
for (const id of [...selected]) {
if (!ids.has(id)) selected.delete(id);
}
render();
break;
}
case 'result':
results.set(message.result.id, message.result);
render();
break;
case 'runComplete':
running = false;
stopping = false;
lastCoverage = message.coverage;
// A stopped run leaves queued tests marked `running`; drop those back to
// pending so the list doesn't show spinners forever.
for (const [id, r] of [...results]) {
if (r.status === 'running') results.delete(id);
}
statusText = message.stopped
? `Stopped · ${message.summary.passed + message.summary.failed} test(s) run`
: '';
render();
break;
case 'reloaded':
reloading = false;
statusText = message.ok
? `Reloaded ${tests.length} tests`
: `Reload failed: ${message.error ?? 'unknown error'}`;
render();
break;
case 'theme':
applyTheme(message.theme);
break;
}
});
applyTheme(new URLSearchParams(window.location.search).get('theme'));
render();
sendToPlugin({ type: 'ready' });