Skip to content

Commit c5a9652

Browse files
test: prove edge conditional-export resolution, and verify with the real Workers bundler (#122)
Closes the last gap from the external review: our edge coverage bundled dist/protect.edge.js DIRECTLY, which proves the artifact is edge-clean but not that a consumer ever reaches it — a mis-ordered or mistyped `exports` condition would silently hand an edge bundler the Node build. - tests/protect/edge-export-resolution.test.ts imports the real specifier (`@patchstack/connect/protect`) from a fixture with the package linked into node_modules, and resolves it under workerd / worker / edge-light / deno / browser, asserting each lands on the edge artifact. The CONTROL is what makes it meaningful: with no edge condition the same import resolves to the Node build and FAILS to bundle for a Node-free target, so a pass is caused by the condition rather than a lenient target. Mutation-checked: pointing `workerd` at dist/protect.js makes it fail. (platform 'neutral' on purpose — 'browser' would inject the `browser` condition and mask whether the edge conditions themselves work.) - scripts/verify-edge-platform.mjs (`npm run verify:edge`) compiles a real Worker with the actual Cloudflare toolchain (`wrangler deploy --dry-run`) and asserts wrangler selected dist/protect.edge.js and emitted a bundle with no Node builtins. Verified locally: wrangler 4 compiles it, 134.90 KiB, zero Node imports. It downloads wrangler and shells out to a platform bundler, so it is deliberately NOT in `npm test` (which CI runs on four Node versions) — the two suite-level tests cover the same property cheaply and this is the end-to-end confirmation for a release job. A native `next build` fixture remains the one uncovered variant. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5c36e9f commit c5a9652

3 files changed

Lines changed: 145 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
],
4545
"scripts": {
4646
"build": "tsup && node scripts/build-edge.mjs && node scripts/copy-protect-templates.mjs",
47+
"verify:edge": "node scripts/verify-edge-platform.mjs",
4748
"dev": "tsup --watch",
4849
"test": "vitest run",
4950
"test:manifest": "bun scripts/test-manifest.ts",

scripts/verify-edge-platform.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// Platform-integration check: compile `@patchstack/connect/protect` with the REAL Cloudflare Workers
2+
// toolchain (wrangler), not a simulation.
3+
//
4+
// Why this exists separately from the test suite: it downloads wrangler and shells out to a platform
5+
// bundler, so it needs network and takes far longer than a unit test — it must not sit in `npm test`
6+
// (which CI runs on four Node versions). The suite covers the same property two cheaper ways:
7+
// - tests/protect/edge-bundle.test.ts — the artifact is edge-bundleable and still enforces
8+
// - tests/protect/edge-export-resolution.test.ts — a consumer's import resolves to the edge branch
9+
// This script is the end-to-end confirmation that a real platform bundler agrees.
10+
//
11+
// node scripts/verify-edge-platform.mjs (or: npm run verify:edge)
12+
//
13+
// Exits non-zero on failure, so it can be wired into a release job.
14+
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, readFileSync, existsSync } from 'node:fs';
15+
import { tmpdir } from 'node:os';
16+
import { join, dirname } from 'node:path';
17+
import { fileURLToPath } from 'node:url';
18+
import { execFileSync } from 'node:child_process';
19+
20+
const repo = fileURLToPath(new URL('..', import.meta.url));
21+
const fail = (msg) => { console.error(`FAIL: ${msg}`); process.exit(1); };
22+
23+
if (!existsSync(join(repo, 'dist', 'protect.edge.js'))) {
24+
console.log('building dist/ first…');
25+
execFileSync('npm', ['run', 'build'], { cwd: repo, stdio: 'inherit' });
26+
}
27+
28+
const dir = mkdtempSync(join(tmpdir(), 'ps-edge-platform-'));
29+
try {
30+
mkdirSync(join(dir, 'node_modules', '@patchstack'), { recursive: true });
31+
symlinkSync(repo, join(dir, 'node_modules', '@patchstack', 'connect'), 'dir');
32+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'ps-edge-fixture', private: true, type: 'module' }));
33+
writeFileSync(join(dir, 'wrangler.toml'), [
34+
'name = "ps-edge-fixture"',
35+
'main = "worker.js"',
36+
'compatibility_date = "2024-09-01"',
37+
'',
38+
].join('\n'));
39+
// A realistic Worker: build the guard once, screen every request through it.
40+
writeFileSync(join(dir, 'worker.js'), [
41+
'import { createProtection } from "@patchstack/connect/protect";',
42+
'let guard;',
43+
'export default {',
44+
' async fetch(request) {',
45+
' guard ??= await createProtection({ rules: { firewall: [], whitelists: [], whitelist_keys: {} }, mode: "block" });',
46+
' return (await guard.fetchGuard()(request)) ?? new Response("ok");',
47+
' },',
48+
'};',
49+
'',
50+
].join('\n'));
51+
52+
console.log('compiling with wrangler (real Workers bundler)…');
53+
execFileSync('npx', ['--yes', 'wrangler@4', 'deploy', '--dry-run', '--outdir=out'], {
54+
cwd: dir,
55+
stdio: 'inherit',
56+
env: { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1' },
57+
});
58+
59+
const out = join(dir, 'out', 'worker.js');
60+
if (!existsSync(out)) fail('wrangler produced no bundle');
61+
const bundle = readFileSync(out, 'utf8');
62+
63+
// The edge artifact is the only one carrying the Node-only stub message: proves the `workerd`
64+
// condition selected it rather than the Node build.
65+
if (!bundle.includes('Node-only')) fail('wrangler resolved the NODE build, not dist/protect.edge.js');
66+
const nodeImports = bundle.match(/from\s*["'](?:node:)?(?:fs|fs\/promises|path|dns|net|os|child_process)["']/g);
67+
if (nodeImports) fail(`Workers bundle references Node builtins: ${[...new Set(nodeImports)].join(', ')}`);
68+
69+
console.log('\nOK: wrangler compiled the Worker, selected dist/protect.edge.js, and the bundle has no Node builtins.');
70+
} finally {
71+
rmSync(dir, { recursive: true, force: true });
72+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, existsSync, rmSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import { join } from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import { execFileSync } from 'node:child_process';
7+
8+
// Resolution test, complementing edge-bundle.test.ts. That one bundles dist/protect.edge.js DIRECTLY,
9+
// which proves the artifact is edge-clean but NOT that a consumer ever reaches it — a mis-ordered or
10+
// mistyped `exports` condition would silently hand an edge bundler the Node build. This test imports
11+
// the real package specifier (`@patchstack/connect/protect`) from a fixture with the package linked into
12+
// node_modules, and resolves it under each edge condition.
13+
//
14+
// The CONTROL is what makes it meaningful: with no edge condition the same import resolves to the Node
15+
// build and FAILS to bundle for a Node-free target. So a pass here is caused by the condition, not by
16+
// the target being lenient.
17+
18+
const repo = fileURLToPath(new URL('../../', import.meta.url));
19+
let dir: string;
20+
21+
async function bundleWith(conditions: string[]): Promise<{ ok: boolean; text: string; errors: string[] }> {
22+
const esbuild = await import('esbuild');
23+
try {
24+
const r = await esbuild.build({
25+
entryPoints: [join(dir, 'entry.js')],
26+
bundle: true,
27+
write: false,
28+
format: 'esm',
29+
// 'neutral' adds no implicit conditions — 'browser' would inject the `browser` condition and mask
30+
// whether the edge conditions themselves work.
31+
platform: 'neutral',
32+
conditions,
33+
absWorkingDir: dir,
34+
logLevel: 'silent',
35+
});
36+
return { ok: true, text: r.outputFiles[0]!.text, errors: [] };
37+
} catch (e: any) {
38+
return { ok: false, text: '', errors: (e.errors ?? []).map((x: any) => x.text) };
39+
}
40+
}
41+
42+
describe('edge conditional-export resolution', () => {
43+
beforeAll(() => {
44+
// The exports map points at dist/, so the artifacts must exist. CI runs tests before the build.
45+
if (!existsSync(join(repo, 'dist', 'protect.edge.js')) || !existsSync(join(repo, 'dist', 'protect.js'))) {
46+
execFileSync('npm', ['run', 'build'], { cwd: repo, stdio: 'ignore' });
47+
}
48+
dir = mkdtempSync(join(tmpdir(), 'ps-export-res-'));
49+
mkdirSync(join(dir, 'node_modules', '@patchstack'), { recursive: true });
50+
symlinkSync(repo, join(dir, 'node_modules', '@patchstack', 'connect'), 'dir');
51+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture', private: true, type: 'module' }));
52+
writeFileSync(join(dir, 'entry.js'), 'import { createProtection } from "@patchstack/connect/protect";\nexport { createProtection };\n');
53+
}, 300_000);
54+
55+
it.each(['workerd', 'worker', 'edge-light', 'deno', 'browser'])(
56+
'resolves @patchstack/connect/protect to the edge build under the %s condition',
57+
async (condition) => {
58+
const r = await bundleWith([condition, 'import']);
59+
expect(r.errors).toEqual([]);
60+
expect(r.ok).toBe(true);
61+
// The edge artifact is the only one carrying the Node-only stub message.
62+
expect(r.text).toContain('Node-only');
63+
},
64+
60_000,
65+
);
66+
67+
it('CONTROL: without an edge condition it resolves to the Node build, which is not edge-bundleable', async () => {
68+
const r = await bundleWith(['import']);
69+
expect(r.ok).toBe(false);
70+
expect(r.errors.join(' ')).toMatch(/Could not resolve "(node:)?(fs|path)"/);
71+
}, 60_000);
72+
});

0 commit comments

Comments
 (0)