feedBack/tests/js/alpha_warning_banner.test.js
Byron Gamatos 9d0bf95716
refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a) (#871)
* refactor(ui)!: remove the classic v2 shell — v3 is the only UI (R3a)

Deletes `static/index.html`, the `/v2` route, and the `FEEDBACK_UI` v2/legacy
opt-out. `/` and `/v3` both serve `static/v3/index.html`, which has been the
default since 0.3.0.

This is step 0 of the core-frontend ES-module migration (R3a). Both shells load
the same `static/app.js`, so every later step of that migration — exposing the
window contract, the `defer` ordering fix, the `type="module"` flips — would
otherwise have to be made and verified twice. Removing the fallback now halves
that surface before any of it is touched.

Incidentally fixes a latent bug in `index()`: its guard read
`if getenv_compat("FEEDBACK_UI") or getenv_compat("FEEDBACK_UI") in ("v2", "legacy")`,
whose left operand is truthy for *any* non-empty value — so `FEEDBACK_UI=v3`
actually served the **v2** shell.

- `static/tailwind.min.css` regenerated: the content globs scanned the deleted
  file, so v2-only utility classes are now purged (CI's tailwind-fresh job
  rebuilds and diffs it).
- Constitution amended to 1.3.0 — Principle II's frontend file list now names
  `static/v3/index.html`.
- Tests: 4 suites read the v2 shell (3 via a constructed `path.join` that a
  literal grep misses). Their v2 halves are paired duplicates of v3 tests that
  stay, so they are dropped; `alpha_warning_banner` and the capability-registry
  script-order test retarget to `static/v3/index.html`.

BREAKING CHANGE: `FEEDBACK_UI=v2` / `=legacy` and the `/v2` route are gone.
Unset the variable and use `/`. No chart, settings, or plugin data changes, and
no plugin API changes — v3 reuses the same engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop the stale '/ is v2' plugin-verification guidance (CodeRabbit)

The v3-only rewrite updated the intro paragraphs but left three lines that
still instructed plugin authors to verify in 'both / (v2) and /v3' — now the
same shell. Historical 'in v2 it was X' contrasts are kept: they still orient
authors whose plugins also ship to users on older cores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:33:03 +02:00

145 lines
5.6 KiB
JavaScript

// Verify the alpha-build heads-up banner: markup is present in
// static/v3/index.html and `_updateAlphaWarningBanner(version)` in
// static/app.js toggles its visibility correctly per the version string.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const INDEX_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
test('index.html ships the alpha-warning banner inside the library section', () => {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
// Locate the library section so we can prove the banner lives there
// and not stuck somewhere it would render off-screen.
const libStart = html.indexOf('id="library-section"');
assert.ok(libStart !== -1, 'library-section anchor not found in index.html');
const libEnd = html.indexOf('</section>', libStart);
assert.ok(libEnd !== -1, 'library-section closing tag not found');
const librarySection = html.slice(libStart, libEnd);
assert.match(
librarySection,
/id="alpha-warning-banner"/,
'alpha-warning-banner must live inside library-section',
);
// `hidden` Tailwind class must be present so the banner stays invisible
// until JS opts it in via classList.toggle('hidden', false).
assert.match(
librarySection,
/id="alpha-warning-banner"[^>]*\bhidden\b/,
'alpha-warning-banner must start with the `hidden` class',
);
// role="status" gives screen readers a non-interrupting announcement
// rather than treating it as decorative.
assert.match(
librarySection,
/id="alpha-warning-banner"[^>]*role="status"/,
'alpha-warning-banner must declare role="status"',
);
});
// Brace-balanced extraction so the function body — including nested
// object literals, template strings, or future guards — survives a
// naive regex stopping at the first `}`.
function extractFunctionSource(src, name) {
const sig = `function ${name}`;
const start = src.indexOf(sig);
assert.ok(start !== -1, `function declaration '${name}' not found`);
const openBrace = src.indexOf('{', start);
assert.ok(openBrace !== -1, `opening brace after '${name}' not found`);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, `unbalanced braces in function '${name}'`);
return src.slice(start, i);
}
// Build a fresh sandbox per test so cross-case state can't leak —
// classList.toggle mutations on one fake banner can't bias the next case.
function setupSandbox({ bannerExists = true } = {}) {
const classes = new Set(['hidden']);
const banner = bannerExists ? {
classList: {
// Mirror DOMTokenList.toggle: when `force` is provided, it
// sets/removes deterministically; the production code relies
// on that signature, so test it the same way.
toggle: (cls, force) => {
if (force === true) classes.add(cls);
else if (force === false) classes.delete(cls);
else if (classes.has(cls)) classes.delete(cls);
else classes.add(cls);
},
},
} : null;
const sandbox = {
document: {
getElementById: (id) => (id === 'alpha-warning-banner' ? banner : null),
},
};
vm.createContext(sandbox);
const src = fs.readFileSync(APP_JS, 'utf8');
const fnSrc = extractFunctionSource(src, '_updateAlphaWarningBanner');
// Hoist into the sandbox global so the test can call it like a
// regular function. The production declaration is inside an IIFE,
// but the function body itself is independent of that closure.
vm.runInContext(`${fnSrc}\nglobalThis.__update = _updateAlphaWarningBanner;`, sandbox);
return { update: sandbox.__update, classes };
}
test('unhides the banner on an alpha version string', () => {
const { update, classes } = setupSandbox();
update('0.2.9-alpha.5');
assert.equal(classes.has('hidden'), false, 'banner should be visible on alpha versions');
});
test('keeps the banner hidden on a stable version string', () => {
const { update, classes } = setupSandbox();
update('0.2.9');
assert.equal(classes.has('hidden'), true, 'banner must stay hidden on stable versions');
});
test('alpha detection is case-insensitive', () => {
const { update, classes } = setupSandbox();
update('0.2.9-ALPHA.5');
assert.equal(classes.has('hidden'), false, 'uppercase ALPHA should also trigger the banner');
});
test('does not confuse beta or rc with alpha', () => {
for (const v of ['0.2.9-beta.1', '0.2.9-rc.2', '1.0.0']) {
const { update, classes } = setupSandbox();
update(v);
assert.equal(
classes.has('hidden'),
true,
`banner must remain hidden on non-alpha version '${v}'`,
);
}
});
test('handles non-string or missing version input gracefully', () => {
for (const v of [null, undefined, 42, {}, '']) {
const { update, classes } = setupSandbox();
update(v);
assert.equal(
classes.has('hidden'),
true,
`banner must stay hidden when version is ${JSON.stringify(v)}`,
);
}
});
test('no-ops without throwing when the banner element is absent', () => {
const { update } = setupSandbox({ bannerExists: false });
assert.doesNotThrow(() => update('0.2.9-alpha.5'));
});