`, spacing the row from what was above it while
* leaving the buttons inside it edge to edge.
*/
const offenders = [];
let sites = 0;
for (const file of sourceFiles()) {
const text = fs.readFileSync(file, 'utf8');
if (!text.includes('{actionButtons}')) continue;
const tags = tagsIn(text);
for (const found of text.matchAll(/\{actionButtons\}/g)) {
// `actionButtons={actionButtons}` is the prop being handed down, not a render.
if (text.slice(found.index - 14, found.index) === 'actionButtons=') continue;
sites += 1;
const parent = enclosingTag(tags, found.index);
if (!isRow(parent)) {
offenders.push(`${path.relative(SRC, file)}: ${
parent ? parent.attributes.trim().slice(0, 40) : 'no enclosing tag'}`);
}
}
}
/*
* Three: the shared collection edit form, which three pages hand a fragment to, and the doc
* page's own two responsive branches. A scan that counted the `actionButtons={...}` props
* instead would reach eight and never look at a render site at all.
*/
expect(sites).toBe(3);
expect(offenders).toEqual([]);
});
it('never lists interpolated buttons as bare siblings', () => {
/*
* The touching itself, and the reported defect: the archive page interpolated six buttons
* one after another straight into its Panel, so every pair shared an edge.
*
* A run of adjacent button interpolations is the shape to look for. Runs inside an
* `actionButtons` fragment are blanked first -- a fragment is spread into its parent's row
* and has no business being one itself, which the previous test is what checks. Blanked
* rather than skipped so every index still points where it did.
*
* The sibling test below covers the other way a row is written; this one only sees `{foo}`.
*/
const offenders = [];
let runs = 0;
for (const file of sourceFiles()) {
let text = fs.readFileSync(file, 'utf8');
for (const fragment of actionFragments(text)) {
text = text.replace(fragment, ' '.repeat(fragment.length));
}
const tags = tagsIn(text);
for (const found of text.matchAll(/\{\w*[Bb]utton\w*\}(?:\s*\{\w*[Bb]utton\w*\})+/g)) {
runs += 1;
const parent = enclosingTag(tags, found.index);
if (!isRow(parent)) {
offenders.push(`${path.relative(SRC, file)}: ${found[0].replace(/\s+/g, '')}`);
}
}
}
/*
* Four: the archive page's actions, the file preview's toolbar (which had a row of its own
* already), the downloads table's edit/restart pair, and the doc page's format picker
* beside its actions. Asserted exactly, because a run that stopped being recognised would
* leave this reporting a clean app.
*/
expect(runs).toBe(4);
expect(offenders).toEqual([]);
});
it('never lists written-out buttons as bare siblings either', () => {
/*
* The same claim for the other way a row is written. The interpolation scan above sees
* `{fooButton}` and nothing else, and most of the app writes its buttons out in place --
* which is how the video page's Download/Delete/Refresh/AddToPlaylist strip, the direct
* analogue of the archive row this PR started with, went on touching after that scan
* reported the app clean. Twelve more rows were in the same state, in table cells, on the
* controller page, and under the one-time-pad and ration calculators.
*
* A "row" is deliberately broad here: the class, a Group or ButtonGroup, or an inline
* flex/grid with a gap, including one whose style is a const declared above. The point is
* whether the buttons are spaced by something, not whether they use this PR's class.
*/
const offenders = [];
let runs = 0;
for (const file of sourceFiles()) {
let text = fs.readFileSync(file, 'utf8');
// A fragment is spread into its parent's row; the earlier test checks that parent.
for (const fragment of actionFragments(text)) {
text = text.replace(fragment, ' '.repeat(fragment.length));
}
const tags = tagsIn(text);
// A button element ending, then whitespace, then another button beginning.
const runPattern = /(?:<\/\w*Button>|<\w*Button\b[^>]*?\/>)\s*<\w*Button\b/g;
for (const match of scannable(text).matchAll(runPattern)) {
// Anchor on the SECOND button's `<`: at the first one's closing tag the pair is
// still open, and the walk would report the first button as its own parent.
const at = match.index + match[0].lastIndexOf('<');
runs += 1;
if (!isRow(enclosingTag(tags, at), text)) {
offenders.push(`${path.relative(SRC, file)}:${
text.slice(0, at).split('\n').length}`);
}
}
}
/*
* Around eighty, which is why this is a scan and not a list. Asserted loosely at both ends
* -- tight enough that a pattern matching nothing shows up, loose enough that adding a
* button to an existing row is not a test failure.
*/
expect(runs).toBeGreaterThan(60);
expect(runs).toBeLessThan(200);
expect(offenders).toEqual([]);
});
it('puts every Back button in a row, so the page stack cannot stretch it', () => {
/*
* A page's blocks are stacked by `.wrolpi-stack`, which is a flex column at
* `align-items: stretch` -- deliberately, because a panel was full-width as a block
* element and has to stay that way. A BARE button as a direct child of that stack is
* stretched too, and every page's Back button was one: full-page-width, and on the domain,
* channel and playlist edit pages the button beside it was pushed onto its own line where
* the two had been inline before.
*
* A row fixes it without touching the stack: the row is the stretched child, and it lays
* its buttons out at their own width. Nine sites, so this scan rather than nine
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
let found = 0;
for (const file of sourceFiles()) {
const text = fs.readFileSync(file, 'utf8');
const tags = tagsIn(text);
for (const match of text.matchAll(/
fs.readdirSync(dir, {withFileTypes: true}).flatMap(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
if (!/\.jsx?$/.test(entry.name)) return [];
if (/\.(test|cy)\.jsx?$/.test(entry.name)) return [];
return [full];
});
/**
* Every `...` in `text` whose enclosing `return` provides page chrome -- a
* `PageContainer` or a `wrolpi-stack` wrapper. Those are the routers that have already spaced and
* inset the page, so a page under one must not do it again.
*
* All of them, and located from the router rather than from the chrome: keying on the first
* `` in a file missed this entirely in Archive.js, where the first one belongs to
* the offending page and holds no router at all.
*/
const chromedRouterBlocks = (text) => {
const blocks = [];
let from = 0;
for (;;) {
const start = text.indexOf('', from);
if (start === -1) return blocks;
const end = text.indexOf('', start);
if (end === -1) return blocks;
from = end + 1;
const returned = text.lastIndexOf('return', start);
if (returned === -1) continue;
const chrome = text.slice(returned, start);
if (chrome.includes('') || chrome.includes('wrolpi-stack')) {
blocks.push(text.slice(start, end));
}
}
};
/**
* The page components a router block routes to. Any capitalised tag that is not routing furniture,
* so `element={}` yields `Page` as well as the plain
* `element={}` form.
*/
const FURNITURE = new Set(['Route', 'Routes', 'ErrorBoundary', 'Suspense', 'Navigate', 'Outlet']);
const routedComponents = (block) => [...new Set(
[...block.matchAll(/<([A-Z]\w*)/g)].map(m => m[1]).filter(name => !FURNITURE.has(name)))];
/**
* The source of the component named `name`, bounded by the next top-level declaration. `class` is
* in both patterns because `ManageZim` is one, and a scan that only knew about functions would
* have reported it clean.
*/
const bodyOf = (text, name) => {
const declaration = new RegExp(`^(?:export\\s+)?(?:function|const|class)\\s+${name}\\b`, 'm');
const found = declaration.exec(text);
if (!found) return ''; // Declared elsewhere; out of this scan's reach.
const after = text.slice(found.index + 1);
const next = after.search(/^(?:export\s+)?(?:function|const|class)\s/m);
return next === -1 ? after : after.slice(0, next);
};