1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181 | /**
* The published surface, path by path.
*
* v1.3.0's dist/ was a tsc file tree and package.json exposed part of it as
* "./games/*" plus "./node". A shadow-cljs :esm build emits flat modules, so
* scripts/gen-subpaths.mjs writes that tree back as re-export shims. Two things
* can silently rot:
* - a shim can claim a name the flat module does not actually export (Node
* throws at import — caught by importing every path below);
* - types/**.d.ts can claim a name from a module that does not export it,
* which `tsc` happily accepts (both files are declarations) and which then
* fails at runtime. So every VALUE a declaration file re-exports is checked
* against the real module here.
*
* Together with test/types/consumer.ts (the type side) this pins the whole
* published API in both directions.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const root = new URL("..", import.meta.url).pathname;
/** Every .js under dist/, as package-relative paths. */
const distFiles = (dir = "dist", out = []) => {
for (const name of readdirSync(join(root, dir)).sort()) {
const p = `${dir}/${name}`;
if (statSync(join(root, p)).isDirectory()) distFiles(p, out);
else if (name.endsWith(".js")) out.push(p);
}
return out;
};
test("every emitted dist module imports, and every name it exports is defined", async () => {
const files = distFiles();
assert.ok(files.length >= 40, `expected the full tree, got ${files.length}`);
let names = 0;
for (const f of files) {
const mod = await import(new URL(`../${f}`, import.meta.url));
for (const [k, v] of Object.entries(mod)) {
if (k === "default") continue;
assert.notEqual(v, undefined, `${f} exports ${k} as undefined`);
names++;
}
}
assert.ok(names >= 150, `expected a wide surface, counted ${names}`);
});
test("the package root exports exactly the v1.3.0 value surface", async () => {
const mod = await import("../dist/index.js");
// the list src/index.ts exported at v1.3.0, values only (types have no
// runtime presence). A name leaving this list is a breaking change.
const expected = [
"DEFAULT_ORIGIN", "GAMES", "GameRoom", "HostDriver", "LampionClient",
"NeonGridClient", "NeonGridGym", "NeonGridHost", "NeonGridSim",
"OdeonClient", "RoomPeer", "SIGNAL", "SUITE_APPS", "SeanceClient",
"SeanceGym", "SeanceHost", "SeanceSim", "Social", "TesseraClient",
"ValleyBlocksClient", "ValleyBlocksEngine", "ValleyBlocksGym",
"ValleyBlocksHost", "ValleyBlocksSim", "boardMetrics", "buildFriendLink",
"connect", "createNet", "dellacherieScore", "enumeratePlacements",
"executePlacement", "installBrowserGlobals", "installOrigin",
"loadIdentity", "mintRoomUrl", "mulberry32", "newRoomSecret",
"parseFriendLink", "placementStep", "planOf", "snakeStep", "stockPick",
"suiteFriendLink",
];
assert.deepEqual(
Object.keys(mod).filter((k) => k !== "default").sort(),
expected,
"the package root's value exports drifted from v1.3.0",
);
});
test("declaration files only re-export names the matching runtime module has", async () => {
// parse `export { A, B } from "../x.js";` out of every types/**.d.ts and
// resolve each name against dist/<x>.js. Type-only names (interfaces, type
// aliases) have no runtime twin, so they are allowed to be absent — what
// must never happen is a name that is absent from the module the DECLARATION
// points at while being present in a different one.
const typeFiles = (dir = "types", out = []) => {
for (const name of readdirSync(join(root, dir)).sort()) {
const p = `${dir}/${name}`;
if (statSync(join(root, p)).isDirectory()) typeFiles(p, out);
else if (name.endsWith(".d.ts")) out.push(p);
}
return out;
};
const modCache = new Map();
const exportsOf = async (rel) => {
if (!modCache.has(rel)) {
modCache.set(rel, new Set(Object.keys(await import(new URL(`../${rel}`, import.meta.url)))));
}
return modCache.get(rel);
};
const RE = /export\s*\{([^}]*)\}\s*from\s*"([^"]+)"/g;
let checked = 0;
for (const tf of typeFiles()) {
const src = readFileSync(join(root, tf), "utf8");
const dir = tf.split("/").slice(1, -1); // path inside types/
for (const m of src.matchAll(RE)) {
const names = m[1].split(",").map((s) => s.trim()).filter(Boolean);
// resolve the specifier relative to the declaration file's directory
const parts = [...dir];
for (const seg of m[2].split("/")) {
if (seg === "..") parts.pop();
else if (seg !== ".") parts.push(seg);
}
const target = `dist/${parts.join("/")}`;
const have = await exportsOf(target);
// where a declaration re-exports a name that IS a value somewhere in the
// package, it must be a value of THIS module
const all = new Set([
...(await exportsOf("dist/index.js")),
...(await exportsOf("dist/internals.js")),
...(await exportsOf("dist/node.js")),
]);
for (const n of names) {
if (!all.has(n)) continue; // a pure type — nothing to check
assert.ok(
have.has(n),
`${tf} re-exports value '${n}' from ${target}, which does not export it`,
);
checked++;
}
}
}
assert.ok(checked >= 60, `expected to check a real surface, checked ${checked}`);
});
test("the historical deep paths still resolve to the same values as the root", async () => {
const root_ = await import("../dist/index.js");
const pairs = [
["../dist/games/room.js", "GameRoom"],
["../dist/games/seance.js", "SeanceClient"],
["../dist/games/neon-grid.js", "NeonGridClient"],
["../dist/games/tessera.js", "TesseraClient"],
["../dist/games/lampion.js", "LampionClient"],
["../dist/games/odeon.js", "OdeonClient"],
["../dist/games/valley-blocks.js", "ValleyBlocksClient"],
["../dist/games/host/rng.js", "mulberry32"],
["../dist/games/host/driver.js", "HostDriver"],
["../dist/games/host/seance-sim.js", "SeanceSim"],
["../dist/games/host/neon-grid-sim.js", "NeonGridSim"],
["../dist/games/host/valley-blocks-sim.js", "ValleyBlocksSim"],
["../dist/games/host/valley-blocks-engine.js", "ValleyBlocksEngine"],
["../dist/games/valley-blocks-brain.js", "stockPick"],
["../dist/gym/seance.js", "SeanceGym"],
["../dist/gym/neon-grid.js", "NeonGridGym"],
["../dist/gym/valley-blocks.js", "ValleyBlocksGym"],
["../dist/node/index.js", "loadIdentity"],
];
for (const [path, name] of pairs) {
const mod = await import(new URL(path, import.meta.url));
assert.equal(mod[name], root_[name], `${path}#${name} is not the same binding as the root's`);
}
});
test("the types-only protocol modules stay empty, as tsc emitted them", async () => {
for (const p of ["../dist/games/host/seance-protocol.js", "../dist/games/host/neon-grid-protocol.js"]) {
const mod = await import(new URL(p, import.meta.url));
assert.deepEqual(Object.keys(mod).filter((k) => k !== "default"), [], `${p} should export nothing`);
}
});
test("package.json's exports map points at files that exist", async () => {
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const check = (p) => statSync(join(root, p.replace("./", "")));
check(pkg.exports["."].import);
check(pkg.exports["."].types);
check(pkg.exports["./node"].import);
check(pkg.exports["./node"].types);
// the wildcard, resolved for one real member
check(pkg.exports["./games/*"].import.replace("*", "room"));
check(pkg.exports["./games/*"].types.replace("*", "room"));
check(pkg.exports["./games/*"].import.replace("*", "host/valley-blocks-core"));
check(pkg.exports["./games/*"].types.replace("*", "host/valley-blocks-core"));
assert.deepEqual(pkg.files, ["src", "dist", "types"]);
assert.equal(pkg.version, "2.1.0");
});
|