Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/lib/architecture-content.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,11 @@ export async function mirrorArtworkUrls(root, urls) {
if (remote.length) {
throw new Error(`remote resource references: ${remote.join('; ')}`);
}
// Stryker disable next-line StringLiteral: equivalent. Node resolves
// an empty encoding argument to the default, which is utf8, so
// emptying the literal writes the same bytes; it records the contract
// rather than selecting it. Pinned by "a mirrored SVG is written as
// UTF-8 bytes" in tests/mutation-equivalence-premises.test.mjs.
writeFileSync(destination, source, 'utf8');
} else {
writeFileSync(destination, fetched);
Expand Down
16 changes: 16 additions & 0 deletions scripts/lib/architecture-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export const REPO_AUTHORED_PAGES = new Set(['index.md', 'submit.md']);
* path, so the ids are reserved here and rejected by the validator.
*/
export const RESERVED_PAGE_IDS = new Set(
// Stryker disable next-line Regex: equivalent. Dropping the `$` only matters
// for a name carrying `.md` somewhere other than its final extension, and
// every repo-authored page name is a single stem plus one trailing
// extension. Pinned by "every repo-authored page name carries exactly one
// trailing .md/.mdx" in tests/mutation-equivalence-premises.test.mjs.
[...REPO_AUTHORED_PAGES].map((page) => page.replace(/\.mdx?$/, '')),
);

Expand Down Expand Up @@ -75,6 +80,17 @@ const PAGE_EXTENSION = /\.mdx?$/;
function resolvesToDirectory(path) {
try {
return statSync(path).isDirectory();
// The surviving BlockStatement mutant on the `catch` below (emptying it,
// so the function returns `undefined` instead of `false`) is equivalent:
// this helper is module-private and its result is read only as the right
// operand of an `||`, so both values are the same decision. It is left
// un-annotated deliberately. A `disable next-line` comment cannot reach
// the catch clause from inside the try block, and a `disable`/`restore`
// pair wide enough to cover it would also suppress the mutant on the try
// block itself, which is killable and killed today. The decision it rests
// on is pinned instead by "a symlink whose target cannot be stat-ed is
// not treated as a directory" in
// tests/mutation-equivalence-premises.test.mjs.
} catch {
return false;
}
Expand Down
6 changes: 6 additions & 0 deletions scripts/lib/cncf-wp-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ export async function fetchAllPages(api, path) {
}
const batch = await response.json();
results.push(...batch);
// Stryker disable next-line StringLiteral: equivalent. Emptying the
// fallback makes `totalPages` 0 instead of 1, and neither value can change
// the walk: `page` starts at 1 and the loop breaks on `page >= totalPages`,
// which is already true for both. Pinned by "a WordPress response with no
// x-wp-totalpages header is fetched once" in
// tests/mutation-equivalence-premises.test.mjs.
const totalPages = Number(response.headers.get('x-wp-totalpages') || '1');
if (page >= totalPages || !batch.length) break;
page += 1;
Expand Down
5 changes: 5 additions & 0 deletions scripts/lib/github.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export async function githubAllPages(url, headers) {
const response = await fetch(next, { headers });
if (!response.ok) throw new Error(`GitHub API ${response.status}: ${next}`);
results.push(...(await response.json()));
// Stryker disable next-line StringLiteral: equivalent. The fallback is
// read only by the `rel="next"` match below, so any value without that
// shape ends pagination exactly as the empty string does. Pinned by "a
// GitHub response with no Link header ends pagination" in
// tests/mutation-equivalence-premises.test.mjs.
const link = response.headers.get('link') || '';
const match = link.match(/<([^>]+)>;\s*rel="next"/);
next = match ? sameOriginNext(match[1], origin) : null;
Expand Down
227 changes: 227 additions & 0 deletions tests/mutation-equivalence-premises.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* Premises that make five mutants equivalent across the import-time libraries
* swept this round: architecture-content.mjs, architecture-pages.mjs,
* cncf-wp-api.mjs and github.mjs.
*
* Each of those mutants is annotated at its site with a `// Stryker disable`
* directive naming the premise it rests on. A premise that quietly stops
* holding turns its annotation into a silent suppression of a real gap, so the
* premises are asserted here rather than left to the comment alone.
*
* These are behavioural assertions against each module's public surface. None
* of them reads a source file as text, so they run unchanged inside Stryker's
* instrumented copy of the tree.
*/

import assert from 'node:assert/strict';
import test from 'node:test';
import {
mkdtempSync,
mkdirSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
REPO_AUTHORED_PAGES,
RESERVED_PAGE_IDS,
listArchitecturePages,
} from '../scripts/lib/architecture-pages.mjs';
import { fetchAllPages } from '../scripts/lib/cncf-wp-api.mjs';
import { githubAllPages } from '../scripts/lib/github.mjs';
import { mirrorArtworkUrls } from '../scripts/lib/architecture-content.mjs';
import {
artworkMirrorPath,
artworkPath,
} from '../scripts/lib/project-assets.mjs';

function sandbox() {
const root = mkdtempSync(join(tmpdir(), 'equivalence-premises-'));
return {
root,
cleanup: () => rmSync(root, { recursive: true, force: true }),
};
}

async function withFetch(stub, run) {
const original = globalThis.fetch;
globalThis.fetch = stub;
try {
return await run();
} finally {
globalThis.fetch = original;
}
}

// --- architecture-pages.mjs: RESERVED_PAGE_IDS strips with an anchored /\.mdx?$/
//
// Dropping the `$` only matters for a name carrying `.md` somewhere other than
// its final extension. No repo-authored page name does, so both forms strip
// the same suffix. Assert the shape rather than the current two names, so a
// page added later that would break the premise fails here.
test('every repo-authored page name carries exactly one trailing .md/.mdx', () => {
assert.ok(REPO_AUTHORED_PAGES.size > 0);
for (const page of REPO_AUTHORED_PAGES) {
assert.match(page, /^[^.]+\.mdx?$/, page);
assert.equal(page.replace(/\.mdx?$/, ''), page.replace(/\.mdx?/, ''), page);
}
});

test('RESERVED_PAGE_IDS holds each page name with its extension removed', () => {
assert.deepEqual(
[...RESERVED_PAGE_IDS].sort(),
[...REPO_AUTHORED_PAGES].map((page) => page.replace(/\.mdx?$/, '')).sort(),
);
});

// --- architecture-pages.mjs: the `catch` in resolvesToDirectory()
//
// The helper is module-private and its result is consumed only as the right
// operand of an `||`, so returning `false` and returning nothing are the same
// decision. What has to keep holding is the decision itself: a symlink whose
// target cannot be stat'd is not treated as a directory, so it is reported
// only when its own name carries a page extension.
test('a symlink whose target cannot be stat-ed is not treated as a directory', () => {
const { root, cleanup } = sandbox();
try {
const docs = join(root, 'docs');
mkdirSync(docs, { recursive: true });
writeFileSync(join(docs, 'index.md'), '#');
// Broken target, and a self-referential link that makes statSync throw
// ELOOP rather than ENOENT -- both reach the catch.
symlinkSync(join(root, 'nonexistent'), join(docs, 'dangling-noext'));
symlinkSync(join(docs, 'loop'), join(docs, 'loop'));
// The same two failures, but named like pages: these are reported on the
// extension test alone, which is what makes the pair above meaningful.
symlinkSync(join(root, 'nonexistent'), join(docs, 'dangling.md'));

const { pages, irregular } = listArchitecturePages(docs);
assert.deepEqual(pages, ['index.md']);
assert.deepEqual(irregular, ['dangling.md']);
} finally {
cleanup();
}
});

// --- cncf-wp-api.mjs: `Number(headers.get('x-wp-totalpages') || '1')`
//
// Emptying the fallback would make `totalPages` 0 instead of 1. Neither value
// can change the walk, because `page` starts at 1 and the loop breaks on
// `page >= totalPages`, which is already true for both. The premise is that
// a response advertising no page count is fetched exactly once.
test('a WordPress response with no x-wp-totalpages header is fetched once', async () => {
let calls = 0;
const results = await withFetch(
async () => {
calls += 1;
return {
ok: true,
status: 200,
json: async () => [{ id: calls }],
headers: { get: () => null },
};
},
() => fetchAllPages('https://www.cncf.io/wp-json/wp/v2', 'posts'),
);
assert.equal(calls, 1);
assert.deepEqual(results, [{ id: 1 }]);
});

test('a WordPress response advertising one page is also fetched once', async () => {
let calls = 0;
await withFetch(
async () => {
calls += 1;
return {
ok: true,
status: 200,
json: async () => [{ id: calls }],
headers: { get: (name) => (name === 'x-wp-totalpages' ? '1' : null) },
};
},
() => fetchAllPages('https://www.cncf.io/wp-json/wp/v2', 'posts'),
);
assert.equal(calls, 1);
});

// --- github.mjs: `response.headers.get('link') || ''`
//
// The fallback is only read by a `rel="next"` match one line later, so any
// value without that shape ends pagination exactly as the empty string does.
// The premise is that a response carrying no usable Link header is the last
// page.
test('a GitHub response with no Link header ends pagination', async () => {
let calls = 0;
const results = await withFetch(
async () => {
calls += 1;
return {
ok: true,
status: 200,
json: async () => [{ id: calls }],
headers: { get: () => null },
};
},
() => githubAllPages('https://api.github.com/orgs/cncf/repos', {}),
);
assert.equal(calls, 1);
assert.deepEqual(results, [{ id: 1 }]);
});

test('a Link header naming no rel="next" target ends pagination', async () => {
let calls = 0;
await withFetch(
async () => {
calls += 1;
return {
ok: true,
status: 200,
json: async () => [{ id: calls }],
headers: {
get: (name) =>
name === 'link'
? '<https://api.github.com/orgs/cncf/repos?page=1>; rel="prev"'
: null,
},
};
},
() => githubAllPages('https://api.github.com/orgs/cncf/repos', {}),
);
assert.equal(calls, 1);
});

// --- architecture-content.mjs: `writeFileSync(destination, source, 'utf8')`
//
// Node resolves an empty encoding argument to the default, which is utf8, so
// emptying the literal writes the same bytes. The literal records the
// contract rather than selecting it, and the contract is what is asserted:
// a mirrored SVG lands on disk as the UTF-8 encoding of the stripped source,
// not as latin1 or any other single-byte reading of it.
test('a mirrored SVG is written as UTF-8 bytes', async () => {
const url =
'https://raw.githubusercontent.com/cncf/artwork/main/projects/helm/icon/color/helm-icon-color.svg';
// Non-ASCII in the title is what makes the encoding observable: every
// character here is multi-byte in UTF-8 and single-byte in latin1.
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><title>Ξ© Γ© δΈ­</title><rect width="1" height="1"/></svg>';
const { root, cleanup } = sandbox();
try {
const destination = join(root, artworkMirrorPath(artworkPath(url)));
await withFetch(
async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new TextEncoder().encode(svg).buffer,
}),
() => mirrorArtworkUrls(root, [url]),
);
assert.deepEqual(readFileSync(destination), Buffer.from(svg, 'utf8'));
assert.equal(readFileSync(destination, 'utf8'), svg);
} finally {
cleanup();
}
});
Loading