diff --git a/scripts/coverage-floor.test.mjs b/scripts/coverage-floor.test.mjs index 49f8dd9..e971af2 100644 --- a/scripts/coverage-floor.test.mjs +++ b/scripts/coverage-floor.test.mjs @@ -80,13 +80,23 @@ export function cliEntrySpans(src) { // top-level body exactly there, and counting braces would trip over the `{` // inside the string and regex literals the checkers are made of. A one-line // declaration has no span (it would otherwise run on to the next body). +// Also matches top-level const arrow-function helpers: +// `const name = (...) => {` / `const name = async (...) => {` / +// `const name = function(...) {` / `const name = async function(...) {`. +// An assigned function closes with `};`, so the const form also accepts a +// `;` right after the brace; the span ends at the brace either way. export function functionSpans(src) { const spans = []; - for (const m of src.matchAll(/^(?:export )?(?:async )?function\b[^\n]*\{$/gm)) { + // Match both function declarations and const arrow-function/function-assigned helpers + for (const m of src.matchAll(/^(?:export )?(?:(?:async )?function\b|const\s+\w+\s*=\s*(?:async\s+)?(?:\(|function\b))[^\n]*\{$/gm)) { const close = src.indexOf("\n}", m.index); if (close < 0) break; + const isConst = /^(?:export )?const\b/.test(m[0]); const after = src[close + 2]; - if (after !== undefined && after !== "\n") continue; + if (after === ";" && isConst) { + const next = src[close + 3]; + if (next !== undefined && next !== "\n") continue; + } else if (after !== undefined && after !== "\n") continue; spans.push([m.index, close + 2]); } return spans; @@ -300,6 +310,43 @@ test("fixture: a range zero in one run but inherited as executed in another is n assert.equal(countOf([], 0, 1), undefined); }); +test("fixture: functionSpans recognizes top-level arrow-const and function-assigned const helpers", () => { + const arrow = "const siteHas = (path) => {\n return path === '';\n}\n"; + const asyncArrow = "const check = async (x) => {\n return x > 0;\n}\n"; + const funcAssigned = "const helper = function(a) {\n return a ? 'yes' : 'no';\n}\n"; + const asyncFuncAssigned = "const asyncHelper = async function(b) {\n return b;\n}\n"; + const exported = "export const f = (x) => {\n return x * 2;\n}\n"; + const mixed = "import x from 'y';\n\nconst a = (p) => {\n if (p) { return 1; }\n return 0;\n}\n\nexport function b() {\n return 2;\n}\n\nconst c = async (q) => {\n return q || null;\n}\n"; + + const arrowSpans = functionSpans(arrow); + assert.equal(arrowSpans.length, 1, "arrow const should have one span"); + assert.equal(arrow.slice(...arrowSpans[0]), arrow.trimEnd(), "arrow const span should cover the entire function up to its closing brace"); + + const asyncArrowSpans = functionSpans(asyncArrow); + assert.equal(asyncArrowSpans.length, 1, "async arrow const should have one span"); + + const funcSpans = functionSpans(funcAssigned); + assert.equal(funcSpans.length, 1, "function-assigned const should have one span"); + + const asyncFuncSpans = functionSpans(asyncFuncAssigned); + assert.equal(asyncFuncSpans.length, 1, "async function-assigned const should have one span"); + + const exportedSpans = functionSpans(exported); + assert.equal(exportedSpans.length, 1, "exported arrow const should have one span"); + + const mixedSpans = functionSpans(mixed); + assert.equal(mixedSpans.length, 3, "mixed file should have three spans (a, b, c)"); + + const semi = "const siteHas = (path) => {\n if (path === '') return true;\n return false;\n};\n\nexport const g = function(x) {\n return x;\n};\n"; + const semiSpans = functionSpans(semi); + assert.equal(semiSpans.length, 2, "arrow/function consts closing with `};` should each have one span"); + assert.equal(semi.slice(...semiSpans[0]), "const siteHas = (path) => {\n if (path === '') return true;\n return false;\n}", "the `};` span ends at the closing brace"); + assert.equal(semi.slice(...semiSpans[1]), "export const g = function(x) {\n return x;\n}"); + assert.deepEqual(functionSpans("const h = (x) => {\n return x;\n}; h(1);\n"), [], "a `};` followed by more code on the line is not a top-level close"); + assert.deepEqual(functionSpans("function d() {\n return 1;\n};\n"), [], "a declaration followed by `;` keeps the old rejection"); +}); + + test("fixture: problems name file, line and the first line of the dead source, sorted by file then offset", () => { const src = "export function f(x) {\n return x\n ? 1\n : 2;\n}\n"; const runs = [[{ functionName: "f", ranges: [{ startOffset: 0, endOffset: src.length, count: 1 }, { startOffset: src.indexOf(": 2"), endOffset: src.indexOf(";"), count: 0 }, { startOffset: src.indexOf("? 1"), endOffset: src.indexOf("\n :"), count: 0 }], isBlockCoverage: true }]]; diff --git a/scripts/issue-forms.test.mjs b/scripts/issue-forms.test.mjs index f146987..e0ce171 100644 --- a/scripts/issue-forms.test.mjs +++ b/scripts/issue-forms.test.mjs @@ -139,6 +139,13 @@ test("every issue form parses and is sound", () => { for (const f of forms) assert.deepEqual(formProblems(parseYaml(readFileSync(join(DIR, f), "utf8"))), [], f); }); +test("fixture: siteHas resolves the root, a bare page, a plain file, and nothing else", () => { + assert.equal(siteHas(""), true); + assert.equal(siteHas("404"), true); + assert.equal(siteHas("robots.txt"), true); + assert.equal(siteHas("no-such-page/"), false); +}); + test("fixtures: each rule rejects a bad input", () => { const ok = () => true; assert.ok(configProblems({ blank_issues_enabled: "yes", contact_links: [] }, ok).length);