From 13c5f16e59da44364af688b27b3f0b6960f2dba1 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 20 Oct 2026 16:24:38 +0200 Subject: [PATCH 1/2] fix regressions --- analytics/test/bundle.test.ts | 84 +++++++++++++++++++++++++++++++++++ analytics/tsdown.config.ts | 12 ++++- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 analytics/test/bundle.test.ts diff --git a/analytics/test/bundle.test.ts b/analytics/test/bundle.test.ts new file mode 100644 index 000000000..401f8b2c2 --- /dev/null +++ b/analytics/test/bundle.test.ts @@ -0,0 +1,84 @@ +/** + * Bundle regression test — the deploy zip is dist/** and nothing else. + * + * serverless.yml packages `dist/**` only: no package.json, no node_modules. + * A bare import that survives into an emitted chunk therefore works locally + * (node_modules is right there) and dies on Scaleway with ERR_MODULE_NOT_FOUND + * before the handler ever runs. That exact failure shipped once: tsdown's + * `deps.alwaysBundle: ["viem"]` matched the bare specifier but not the + * `viem/chains` subpath, the function 500ed on import, the platform's error + * preflight broke CORS, and the dashboard showed "Failed to fetch". + * + * tsdown's `deps.onlyImport` already fails the *build* on a stray bare + * specifier, but nothing runs this package's build in CI — this test catches + * the same failure independently of that config, by reproducing the production + * environment: build with the real tsdown.config.ts into an empty temp + * directory (no node_modules above it), then import both entries there the way + * the Scaleway runtime does. + * + * The import runs in a child `node` process, not a dynamic import here: + * vitest's module runner would resolve the chunk's bare imports against this + * project's node_modules and hide the very failure under test. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtemp, rm, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { build, type InlineConfig, type UserConfig } from "tsdown"; +import userConfig from "../tsdown.config.js"; + +const execFileAsync = promisify(execFile); + +// The two handlers serverless.yml deploys (functions.analytics, functions.rollup). +const ENTRIES = ["analytics.js", "rollup.js"] as const; + +describe("bundled dist", () => { + let outDir: string; + + beforeAll(async () => { + // A fresh tmp dir has no node_modules above it, so a bare import that + // survived bundling fails to resolve here exactly as it does on Scaleway. + outDir = await mkdtemp(path.join(tmpdir(), "analytics-dist-")); + await build({ ...(userConfig as UserConfig), outDir } as InlineConfig); + }, 60_000); + + afterAll(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("emits both deployed handlers", async () => { + const files = await readdir(outDir); + for (const entry of ENTRIES) { + expect(files, `expected ${entry} in the build output`).toContain(entry); + } + }); + + it("loads in an environment without node_modules — no bare import survives bundling", async () => { + const entryUrls = ENTRIES.map((entry) => pathToFileURL(path.join(outDir, entry)).href); + try { + const { stdout } = await execFileAsync( + process.execPath, + [ + "--input-type=module", + "-e", + `const mods = await Promise.all((process.env.ENTRY_URLS ?? "").split("\\n").map((url) => import(url))); + for (const [index, mod] of mods.entries()) { + if (typeof mod.handle !== "function") throw new Error(\`entry \${index} does not export handle()\`); + }`, + ], + { env: { ...process.env, ENTRY_URLS: entryUrls.join("\n") }, timeout: 30_000 }, + ); + expect(stdout.trim()).toBe(""); + } catch (err) { + // The child's stderr names the offending package (ERR_MODULE_NOT_FOUND) — + // surface it, since the whole point is knowing which import was not bundled. + expect( + (err as { stderr?: string }).stderr ?? String(err), + "a bare import survived bundling — the deploy zip ships dist/** with no node_modules, so it is fatal on Scaleway", + ).toBe(""); + } + }); +}); diff --git a/analytics/tsdown.config.ts b/analytics/tsdown.config.ts index 7fefe2c6c..5194299d6 100644 --- a/analytics/tsdown.config.ts +++ b/analytics/tsdown.config.ts @@ -28,7 +28,17 @@ export default defineConfig({ // @fretchen/chain-utils imports it directly) but tsdown's alwaysBundle // doesn't bundle a package's own dependencies unless they're named too — // omitting it here shipped a broken `import "viem"` straight to production. - alwaysBundle: ["@fretchen/s3-utils", "@fretchen/chain-utils", "viem", "isbot"], + // The viem entry is a regex, not the string "viem": tsdown matches string + // patterns exactly or via picomatch, and neither covers a subpath, so + // "viem/chains" — a side-effect import @fretchen/chain-utils' barrel + // re-emits — stayed external, crashed the deployed function with + // ERR_MODULE_NOT_FOUND, and surfaced in the dashboard as "Failed to fetch". + alwaysBundle: ["@fretchen/s3-utils", "@fretchen/chain-utils", /^viem(\/|$)/, "isbot"], + // Build-time guard for the same failure: any bare specifier that survives + // into the emitted chunks and is not one of the two dev-only externals + // above fails the build, instead of shipping a dist that cannot load + // without node_modules. Relative imports and node: builtins are skipped. + onlyImport: ["dotenv", "@scaleway/serverless-functions"], }, // createRequire banner for packages that use dynamic require() (like pino). // The import is aliased: rolldown's own runtime chunks import `createRequire` From 0f348fb18d68377026aa95aa513d693a0dc45d04 Mon Sep 17 00:00:00 2001 From: fretchen Date: Tue, 20 Oct 2026 16:24:43 +0200 Subject: [PATCH 2/2] Create test-analytics.yml --- .github/workflows/test-analytics.yml | 67 ++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/test-analytics.yml diff --git a/.github/workflows/test-analytics.yml b/.github/workflows/test-analytics.yml new file mode 100644 index 000000000..abddd8a92 --- /dev/null +++ b/.github/workflows/test-analytics.yml @@ -0,0 +1,67 @@ +name: Test Analytics Service + +permissions: + contents: read + +on: + pull_request: + paths: + - "analytics/**" + - "shared/chain-utils/**" + - "shared/s3-utils/**" + - ".github/workflows/test-analytics.yml" + push: + branches: + - main + paths: + - "analytics/**" + - "shared/chain-utils/**" + - "shared/s3-utils/**" + - ".github/workflows/test-analytics.yml" + +jobs: + test-analytics: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + analytics/package-lock.json + shared/chain-utils/package-lock.json + shared/s3-utils/package-lock.json + + # Both shared packages must exist with a built dist/ before analytics' + # npm ci — the file: dependencies snapshot them into node_modules at + # install time, so building after would leave node_modules without dist. + # test-facilitator.yml does the same for chain-utils; analytics is the + # only consumer that also depends on @fretchen/s3-utils. + - name: Build chain-utils dependency + working-directory: ./shared/chain-utils + run: npm ci && npm run build + + - name: Build s3-utils dependency + working-directory: ./shared/s3-utils + run: npm ci && npm run build + + - name: Install dependencies + working-directory: ./analytics + run: npm ci + + # npm run check = lint + format:check + typecheck + test:coverage. The + # coverage run includes test/bundle.test.ts, which rebuilds dist/ with + # the real tsdown.config.ts and loads both handlers in a child process + # without node_modules — the failure mode that shipped a broken + # function to production once. + - name: Run quality checks (lint + format + test with coverage) + working-directory: ./analytics + run: npm run check + env: + NODE_ENV: test + CI: true