Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/workflows/test-analytics.yml
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions analytics/test/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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("");
}
});
});
12 changes: 11 additions & 1 deletion analytics/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading