Skip to content

Commit 1185a59

Browse files
committed
test(cli): pin base-path normalizer parity between the CLI port and the server
readAppBasePath in packages/cli/lib/doctor.js is a hand-maintained port of normalizeBasePath, kept as a port because doctor must run when the framework does not resolve from the app dir at all (#954). Nothing tested that the two agree, and base-path.js has already changed twice since the port landed. One input table now runs through both and asserts three-way against the expected value, so drift is a red test instead of a silent disagreement between what the UNMARKED_ASSET_LINKS check assumes and what the server serves.
1 parent 207f216 commit 1185a59

3 files changed

Lines changed: 133 additions & 2 deletions

File tree

packages/cli/lib/doctor.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,11 +1396,19 @@ function unmarkedStylesheetHref(tag, basePath = '') {
13961396
* Ported rather than imported because that helper is not on `@webjsdev/server`'s
13971397
* public surface, and because doctor must stay usable when the framework does
13981398
* not resolve from the app dir at all (the #954 fresh-worktree case this same
1399-
* command exists to diagnose). `test/cli/doctor.test.mjs` pins the forms.
1399+
* command exists to diagnose). The port is intentional and stays. What makes it
1400+
* safe is that the drift is tested rather than trusted.
1401+
*
1402+
* `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
1403+
* function and the server's `readBasePath`, asserting they agree with each other
1404+
* and with the expected value. Change either side without the other and it reds.
1405+
* So edit this body only alongside `packages/server/src/base-path.js`, and run
1406+
* that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
1407+
* not the normalization forms themselves.)
14001408
* @param {string} appDir
14011409
* @returns {Promise<string>}
14021410
*/
1403-
async function readAppBasePath(appDir) {
1411+
export async function readAppBasePath(appDir) {
14041412
let raw;
14051413
try {
14061414
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));

packages/server/src/base-path.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,16 @@
5454
* or a hostile value fails safe to "no base path" rather than poisoning
5555
* every emitted URL.
5656
*
57+
* This function is PORTED into the CLI, as `readAppBasePath`
58+
* (`packages/cli/lib/doctor.js`), because doctor must run when
59+
* `@webjsdev/server` does not resolve from the app dir at all (#954). The port
60+
* is deliberate, so the two are kept honest by
61+
* `test/cli/base-path-parity.test.mjs`, which runs one input table through both
62+
* and asserts they agree. Edit this body and that test reds until the port
63+
* follows. The ordering below matters most: the `//host` rejection sits BEFORE
64+
* the leading-slash collapse, and reordering it on either side is an origin
65+
* escape the parity table catches.
66+
*
5767
* @param {unknown} raw the configured value
5868
* @returns {string} `''` or `/segment[/segment...]`
5969
*/

test/cli/base-path-parity.test.mjs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Parity between the two `webjs.basePath` normalizers (#1300, part 1).
3+
*
4+
* `normalizeBasePath` (`packages/server/src/base-path.js`) is the source of
5+
* truth for what a base path means, and `readAppBasePath`
6+
* (`packages/cli/lib/doctor.js`) is a hand-maintained PORT of it, because
7+
* doctor must run when `@webjsdev/server` does not resolve from the app dir at
8+
* all (#954, the fresh-worktree case doctor exists to diagnose). The port is
9+
* deliberate and stays. This file is what stops it drifting silently.
10+
*
11+
* Every row asserts THREE-WAY: the CLI port equals the server reader equals the
12+
* expected value. Equality alone would pass if both drifted the same way, and
13+
* the expected column alone would not prove the two agree, so neither assertion
14+
* is redundant.
15+
*
16+
* The rows that matter most are the `//host` ones. Both implementations reject a
17+
* network-path reference BEFORE collapsing leading slashes. Collapsing first
18+
* would turn `//evil.com` into `/evil.com` and prefix every emitted URL with an
19+
* origin escape. Move that guard below the collapse on EITHER side and those
20+
* rows red.
21+
*/
22+
import { test } from 'node:test';
23+
import assert from 'node:assert/strict';
24+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
25+
import { tmpdir } from 'node:os';
26+
import { join } from 'node:path';
27+
28+
import { readBasePath } from '../../packages/server/src/base-path.js';
29+
import { readAppBasePath } from '../../packages/cli/lib/doctor.js';
30+
31+
/** @type {string[]} */
32+
const dirs = [];
33+
function tmpApp() {
34+
const dir = mkdtempSync(join(tmpdir(), 'webjs-basepath-parity-'));
35+
dirs.push(dir);
36+
return dir;
37+
}
38+
test.after(() => {
39+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
40+
});
41+
42+
/**
43+
* `[label, raw basePath value, expected normalized form]`. `MISSING` means the
44+
* `basePath` key is absent entirely, which is the default every unconfigured
45+
* app takes.
46+
*/
47+
const MISSING = Symbol('basePath key omitted');
48+
const ROWS = [
49+
['key omitted', MISSING, ''],
50+
['a number', 42, ''],
51+
['a boolean', true, ''],
52+
['null', null, ''],
53+
['an object', {}, ''],
54+
['an array', [], ''],
55+
['the empty string', '', ''],
56+
['whitespace only', ' ', ''],
57+
['the root path', '/', ''],
58+
['padded with whitespace', ' /app ', '/app'],
59+
['no leading slash', 'app', '/app'],
60+
['already canonical', '/app', '/app'],
61+
['one trailing slash', '/app/', '/app'],
62+
['several trailing slashes', '/app///', '/app'],
63+
// Not `/app`. The network-path guard fires on the `//` prefix before the
64+
// leading-slash collapse can run, so the collapse is unreachable for more
65+
// than one leading slash and this value fails safe like any other `//host`.
66+
['several leading slashes', '///app', ''],
67+
['a nested path', '/foo/bar', '/foo/bar'],
68+
['a nested path with a trailing slash', '/foo/bar/', '/foo/bar'],
69+
['a leading traversal', '../app', ''],
70+
['a mid-path traversal', '/app/../x', ''],
71+
['an absolute url', 'https://evil.com', ''],
72+
['a backslash', '/app\\x', ''],
73+
['interior whitespace', '/my app', ''],
74+
['an interior tab', '/app\tx', ''],
75+
['a network-path host', '//evil.com', ''],
76+
['a network-path host with a path', '//evil.com/app', ''],
77+
['a bare double slash', '//', ''],
78+
['a bare triple slash', '///', ''],
79+
];
80+
81+
for (const [label, raw, expected] of ROWS) {
82+
test(`basePath parity: ${label}`, async () => {
83+
const dir = tmpApp();
84+
const webjs = raw === MISSING ? {} : { basePath: raw };
85+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'x', webjs }));
86+
87+
const fromCli = await readAppBasePath(dir);
88+
const fromServer = readBasePath({ webjs });
89+
90+
assert.equal(fromServer, expected, `server reader normalized ${label} wrongly`);
91+
assert.equal(fromCli, expected, `CLI port normalized ${label} wrongly`);
92+
assert.equal(fromCli, fromServer, `the CLI port and the server reader disagree on ${label}`);
93+
});
94+
}
95+
96+
/**
97+
* The two file-level branches, where only the CLI port has a code path (the
98+
* server reader takes a parsed object, so `dev.js` owns the read). Both yield
99+
* `''`, which is what `readBasePath` returns for the `undefined` it would have
100+
* been handed.
101+
*/
102+
test('basePath parity: a missing package.json reads as no base path', async () => {
103+
const dir = tmpApp();
104+
assert.equal(await readAppBasePath(dir), '');
105+
assert.equal(readBasePath(undefined), '');
106+
});
107+
108+
test('basePath parity: an unparseable package.json reads as no base path', async () => {
109+
const dir = tmpApp();
110+
writeFileSync(join(dir, 'package.json'), '{ this is not json');
111+
assert.equal(await readAppBasePath(dir), '');
112+
assert.equal(readBasePath(undefined), '');
113+
});

0 commit comments

Comments
 (0)