Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,11 @@
export function failedBaselineGateIds(results) {
return results.filter((result) => result.exitCode !== 0 || result.failureCount > 0).map((result) => result.id);
}
export function markFailedBaselineGates(results) {
const failedGateIds = failedBaselineGateIds(results);
if (failedGateIds.length === 0) return;
console.error(`Baseline gates failed: ${failedGateIds.join(", ")}`);
process.exitCode = 1;
}
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
# Build the Phase 06 plugin runtime external-audit package.
#
# Produces a single tar.gz containing:
# - the in-scope source code (a curated subset, NOT the whole repo)
# - the threat model, attack-surface inventory, internal findings
# - the RFP / sign-off template
# - the manifest of what's included (with SHA-256 per file)
#
# Operator workflow:
# 1. Run from the repo root: scripts/build-plugin-audit-package.sh
# 2. Output lands in artifacts/plugin-audit/<ISO-date>/plugin-audit-<short-sha>.tar.gz
# 3. Email the archive to the vendor per the RFP § 8.
# 4. Keep the archive AND its checksum locally for forensics — the vendor
# receives the same bytes.
#
# Dependencies: bash, tar, sha256sum (or shasum -a 256), git. macOS + Linux compatible.
set -euo pipefail
# ---- locate repo root ------------------------------------------------------
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "${REPO_ROOT}" ] || [ ! -d "${REPO_ROOT}/.git" ]; then
echo "ERROR: must run inside the boilerplate git repo." >&2
exit 1
fi
cd "${REPO_ROOT}"
# ---- pick sha256 binary ----------------------------------------------------
if command -v sha256sum >/dev/null 2>&1; then
SHA256="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
SHA256="shasum -a 256"
else
echo "ERROR: need sha256sum or shasum on PATH." >&2
exit 1
fi
# ---- destination -----------------------------------------------------------
SHORT_SHA="$(git rev-parse --short HEAD)"
DATE_ISO="$(date -u +%Y-%m-%d)"
OUT_DIR="artifacts/plugin-audit/${DATE_ISO}"
ARCHIVE_NAME="plugin-audit-${SHORT_SHA}.tar.gz"
ARCHIVE_PATH="${OUT_DIR}/${ARCHIVE_NAME}"
MANIFEST_PATH="${OUT_DIR}/MANIFEST-${SHORT_SHA}.txt"
STAGE_DIR="$(mktemp -d -t plugin-audit-XXXXXX)"
trap 'rm -rf "${STAGE_DIR}"' EXIT
mkdir -p "${OUT_DIR}"
echo "→ Building plugin audit package"
echo " repo: ${REPO_ROOT}"
echo " HEAD: ${SHORT_SHA}"
echo " out: ${ARCHIVE_PATH}"
echo " stage: ${STAGE_DIR}"
# ---- in-scope file list ----------------------------------------------------
# Append-only list. Keep aligned with docs/security/plugin-attack-surface.md § 1-6.
# Each path is relative to the repo root. Glob patterns expanded by bash.
INSCOPE_PATHS=(
# Runtime
"packages/plugin-runtime/src"
"packages/plugin-runtime/package.json"
"packages/plugin-runtime/tsconfig.json"
# SDK
"packages/plugin-sdk/src"
"packages/plugin-sdk/examples"
"packages/plugin-sdk/package.json"
"packages/plugin-sdk/tsconfig.json"
# Worker handlers
"apps/worker/src/jobs/plugin-invoke.ts"
"apps/worker/src/jobs/plugin-invoke.test.ts"
"apps/worker/src/jobs/plugin-capability-use.ts"
"apps/worker/src/jobs/plugin-capability-use.test.ts"
"apps/worker/src/jobs/plugin-audit-retention.ts"
"apps/worker/src/jobs/plugin-audit-retention.test.ts"
# Web routes + lifecycle service. `$tenant` and `$id` here are literal
# React Router path-segment names, NOT shell variables — we read array
# elements without `eval` (see staging loop below) so they stay literal.
'apps/web/app/routes/api.tenants.$tenant.plugins.tsx'
'apps/web/app/routes/api.tenants.$tenant.plugins.test.ts'
'apps/web/app/routes/app.$tenant/settings/plugins.tsx'
'apps/web/app/routes/app.$tenant/settings/plugins.test.ts'
'apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx'
"apps/web/app/modules/plugins"
# CLI plugin commands
"packages/cli/src/commands/plugins"
# Prisma schema for plugin tables
"prisma/schema/plugins.prisma"
# Documentation
"docs/security/plugin-threat-model.md"
"docs/security/plugin-attack-surface.md"
"docs/security/plugin-internal-findings.md"
"docs/security/plugin-audit-rfp.md"
"docs/security/plugin-runtime-signoff.md"
"docs/architecture/plugin-system.md"
"docs/architecture/plugin-hooks.md"
"docs/plugins"
"docs/improvement-plan/06-plugin-system"
# Env example (operator-visible config surface)
".env.example"
)
# ---- stage files -----------------------------------------------------------
# We copy paths preserving directory structure under ${STAGE_DIR}/code/.
PKG_ROOT="${STAGE_DIR}/plugin-audit-${SHORT_SHA}"
mkdir -p "${PKG_ROOT}/code"
MISSING=()
for path in "${INSCOPE_PATHS[@]}"; do
# No eval / re-expansion — the path is taken literally. React Router
# filenames contain literal `$tenant` / `$id` which we must NOT interpret
# as shell variables.
if [ ! -e "${path}" ]; then
MISSING+=("${path}")
continue
fi
parent_dir="$(dirname "${path}")"
mkdir -p "${PKG_ROOT}/code/${parent_dir}"
cp -R "${path}" "${PKG_ROOT}/code/${parent_dir}/"
done
# ---- include git HEAD metadata --------------------------------------------
git rev-parse HEAD > "${PKG_ROOT}/HEAD.txt"
git log -1 --format="%H%n%an <%ae>%n%aI%n%s" > "${PKG_ROOT}/HEAD-meta.txt"
git status --porcelain > "${PKG_ROOT}/WORKTREE-DIRTY.txt"
if [ -s "${PKG_ROOT}/WORKTREE-DIRTY.txt" ]; then
echo " WARNING: worktree is dirty — package reflects uncommitted state." >&2
echo " See ${PKG_ROOT}/WORKTREE-DIRTY.txt inside the archive." >&2
fi
# ---- readme inside the archive --------------------------------------------
cat > "${PKG_ROOT}/README.txt" <<'PKG_README'
Plugin Runtime External Audit Package
=====================================
This archive contains the Phase 06 plugin runtime code surface plus the
internal documentation we have produced. It is the input to the external
pen-test described in docs/security/plugin-audit-rfp.md.
What's inside
-------------
code/ Curated subset of the repo (see scripts/build-plugin-audit-package.sh
for the in-scope list).
HEAD.txt git HEAD SHA the archive was built from.
HEAD-meta.txt Committer + date + subject of HEAD.
WORKTREE-DIRTY.txt Uncommitted changes at build time (should be empty).
MANIFEST.txt SHA-256 of every file in this archive.
Where to start reading
----------------------
1. code/docs/security/plugin-audit-rfp.md — what we're asking for
2. code/docs/security/plugin-threat-model.md — what we worry about
3. code/docs/security/plugin-attack-surface.md — interface inventory
4. code/docs/security/plugin-internal-findings.md — what we've already found
5. code/packages/plugin-runtime/src — the runtime itself
6. code/apps/worker/src/jobs/plugin-*.ts — worker entry points
7. code/prisma/schema/plugins.prisma — data model
Re-test workflow
----------------
After remediation lands on `main`, re-run the build script and send the
new archive (with its delta vs. the previous SHORT_SHA).
PKG_README
# ---- per-file SHA-256 manifest --------------------------------------------
(
cd "${PKG_ROOT}"
if [ "${SHA256}" = "sha256sum" ]; then
find . -type f ! -name "MANIFEST.txt" -print0 | sort -z | xargs -0 sha256sum > MANIFEST.txt
else
# shasum -a 256 expects file args; use find -print0 + xargs
find . -type f ! -name "MANIFEST.txt" -print0 | sort -z | xargs -0 shasum -a 256 > MANIFEST.txt
fi
)
# ---- pack ------------------------------------------------------------------
tar -czf "${ARCHIVE_PATH}" -C "${STAGE_DIR}" "plugin-audit-${SHORT_SHA}"
${SHA256} "${ARCHIVE_PATH}" > "${MANIFEST_PATH}"
# ---- summary --------------------------------------------------------------
echo
echo "✔ Archive built:"
echo " ${ARCHIVE_PATH}"
echo " $(wc -c < "${ARCHIVE_PATH}") bytes"
echo
echo "✔ Outer-archive SHA-256:"
cat "${MANIFEST_PATH}"
echo
if [ ${#MISSING[@]} -gt 0 ]; then
echo "⚠ Missing in-scope paths (omitted from archive):"
for m in "${MISSING[@]}"; do echo " ${m}"; done
fi
echo
echo "Next steps:"
echo " 1. Verify the archive: tar -tzf ${ARCHIVE_PATH} | head"
echo " 2. Email the archive to the vendor per docs/security/plugin-audit-rfp.md § 8."
echo " 3. Record the archive checksum in your engagement tracker."
@@ -0,0 +1,73 @@
import { mkdir } from "node:fs/promises";
import { builtinModules } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "esbuild";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const entryPoint = path.join(rootDir, "server/server.ts");
const outputFile = path.join(rootDir, "build/server-runtime/server.js");
const nodeBuiltins = new Set([...builtinModules, ...builtinModules.map((moduleName) => `node:${moduleName}`)]);
const buildSourcemapsEnabled =
process.env.TOOLCTRL_BUILD_SOURCEMAPS === "1" ||
process.env.SOURCEMAP === "true" ||
process.env.BUILD_SOURCEMAPS === "true";
const externalizeRuntimeDependencies = {
name: "externalize-runtime-dependencies",
setup(buildContext) {
buildContext.onResolve({ filter: /.*/ }, (args) => {
if (nodeBuiltins.has(args.path)) {
return { path: args.path, external: true };
}
if (isExternalRuntimePackage(args.path)) {
return { path: args.path, external: true };
}
return undefined;
});
},
};
await mkdir(path.dirname(outputFile), { recursive: true });
await build({
entryPoints: [entryPoint],
outfile: outputFile,
bundle: true,
platform: "node",
format: "esm",
target: "node20",
sourcemap: buildSourcemapsEnabled,
banner: {
js: 'import { createRequire as __toolctrlCreateRequire } from "node:module"; const require = __toolctrlCreateRequire(import.meta.url);',
},
plugins: [externalizeRuntimeDependencies],
logLevel: "warning",
});
function isBarePackageSpecifier(specifier) {
return !specifier.startsWith(".") && !specifier.startsWith("/") && !specifier.startsWith("#");
}
function isExternalRuntimePackage(specifier) {
if (!isBarePackageSpecifier(specifier)) {
return false;
}
return (
specifier === "react-router" ||
specifier.startsWith("react-router/") ||
specifier === "@react-router/node" ||
specifier.startsWith("@react-router/node/") ||
specifier === "@prisma/client" ||
specifier.startsWith("@prisma/client/") ||
specifier === "@prisma/adapter-pg" ||
specifier.startsWith("@prisma/adapter-pg/") ||
specifier === "jsonwebtoken" ||
specifier.startsWith("jsonwebtoken/") ||
specifier === "pino" ||
specifier.startsWith("pino/")
);
}
@@ -0,0 +1,38 @@
import fs from "node:fs";
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "vitest";
const scriptSource = fs.readFileSync(path.join(path.dirname(new URL(import.meta.url).pathname), "build-web-server.mjs"), "utf8");
describe("build-web-server", () => {
it("bundles the server entrypoint into the production runtime artifact", () => {
assert.match(scriptSource, /server\/server\.ts/);
assert.match(scriptSource, /build\/server-runtime\/server\.js/);
});
it("pins a Node ESM esbuild output instead of a runtime tsx command", () => {
assert.match(scriptSource, /platform:\s*"node"/);
assert.match(scriptSource, /format:\s*"esm"/);
assert.match(scriptSource, /target:\s*"node20"/);
assert.match(scriptSource, /sourcemap:\s*buildSourcemapsEnabled/);
assert.match(scriptSource, /logLevel:\s*"warning"/);
assert.doesNotMatch(scriptSource, /tsx/);
});
it("keeps server sourcemaps opt-in through the documented env contract", () => {
assert.match(scriptSource, /const buildSourcemapsEnabled\s*=/);
assert.match(scriptSource, /process\.env\.TOOLCTRL_BUILD_SOURCEMAPS\s*===\s*"1"/);
assert.match(scriptSource, /process\.env\.SOURCEMAP\s*===\s*"true"/);
assert.match(scriptSource, /process\.env\.BUILD_SOURCEMAPS\s*===\s*"true"/);
assert.doesNotMatch(scriptSource, /sourcemap:\s*true/);
});
it("keeps runtime packages external to avoid bundling browser or native assets", () => {
assert.match(scriptSource, /externalizeRuntimeDependencies/);
assert.match(scriptSource, /plugins:\s*\[externalizeRuntimeDependencies\]/);
assert.match(scriptSource, /function isExternalRuntimePackage/);
assert.match(scriptSource, /specifier === "react-router"/);
assert.doesNotMatch(scriptSource, /packages:\s*"external"/);
});
});
@@ -0,0 +1,50 @@
{
"$schema": "./bundle-budgets.schema.json",
"comment": "Per-route + vendor-chunk bundle size budgets in GZIP bytes. The hard raw-JS initial-payload budget lives in scripts/verify-bundle-size.mjs as BUDGETS.initial_payload_js. See docs/architecture/bundle-budgets.md.",
"default": { "limitKb": 200, "warningKb": 180 },
"routes": {
"app/$tenant/dashboard": { "limitKb": 250, "warningKb": 225 },
"app/$tenant/crm": { "limitKb": 250, "warningKb": 225 },
"app/$tenant/crm/contacts": { "limitKb": 200, "warningKb": 180 },
"app/$tenant/settings/members": { "limitKb": 220, "warningKb": 200 },
"app/$tenant/settings/billing": { "limitKb": 220, "warningKb": 200 },
"app/$tenant/email-marketing": { "limitKb": 280, "warningKb": 250 },
"app/$tenant/workflows": { "limitKb": 300, "warningKb": 275 },
"workflow-engine": { "limitKb": 80, "warningKb": 70 },
"workflows": { "limitKb": 80, "warningKb": 70 },
"workflows._id": { "limitKb": 80, "warningKb": 70 },
"workflows._id.executions": { "limitKb": 60, "warningKb": 50 },
"workflows._id.run.api": { "limitKb": 60, "warningKb": 50 },
"workflows._id.run.manual": { "limitKb": 60, "warningKb": 50 },
"workflows._id.run.stream": { "limitKb": 60, "warningKb": 50 },
"workflows._id.run.stream._executionId": { "limitKb": 20, "warningKb": 15 },
"chatbot": { "limitKb": 60, "warningKb": 50 },
"chatbot-widget": { "limitKb": 60, "warningKb": 50 },
"chatbot-interactive-widget": { "limitKb": 80, "warningKb": 70 },
"ai-usage": { "limitKb": 60, "warningKb": 50 },
"pricing.ai-overage": { "limitKb": 60, "warningKb": 50 },
"kb/$slug": { "limitKb": 150, "warningKb": 135 },
"auth/login": { "limitKb": 100, "warningKb": 90 },
"auth/register": { "limitKb": 100, "warningKb": 90 },
"auth/magic-link": { "limitKb": 80, "warningKb": 75 },
"admin/dashboard": { "limitKb": 220, "warningKb": 200 },
"admin/analytics": { "limitKb": 300, "warningKb": 275 },
"admin/users": { "limitKb": 220, "warningKb": 200 },
"/": { "limitKb": 180, "warningKb": 160 }
},
"vendorChunks": {
"vendor-misc": { "limitKb": 300, "warningKb": 280 },
"vendor-react": { "limitKb": 150, "warningKb": 135 },
"vendor-charts": { "limitKb": 200, "warningKb": 180 },
"vendor-react-email": { "limitKb": 150, "warningKb": 135 },
"vendor-ai-sdk": { "limitKb": 120, "warningKb": 110 },
"vendor-editor-tiptap": { "limitKb": 120, "warningKb": 110 },
"vendor-editor-prosemirror": { "limitKb": 150, "warningKb": 135 },
"vendor-markdown": { "limitKb": 80, "warningKb": 70 },
"vendor-monaco": { "limitKb": 250, "warningKb": 230 },
"vendor-pdf": { "limitKb": 250, "warningKb": 230 },
"vendor-workflow": { "limitKb": 200, "warningKb": 180 },
"vendor-aws-sdk": { "limitKb": 250, "warningKb": 230 },
"vendor-core-utils": { "limitKb": 60, "warningKb": 50 }
}
}
@@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Bundle Budgets Config",
"description": "Per-route and per-vendor-chunk gzip-size budgets used by scripts/verify-bundle-size.mjs.",
"type": "object",
"additionalProperties": false,
"required": ["default", "routes", "vendorChunks"],
"properties": {
"$schema": { "type": "string" },
"comment": { "type": "string" },
"default": { "$ref": "#/definitions/budget" },
"routes": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/budget" }
},
"vendorChunks": {
"type": "object",
"additionalProperties": { "$ref": "#/definitions/budget" }
}
},
"definitions": {
"budget": {
"type": "object",
"additionalProperties": false,
"required": ["limitKb", "warningKb"],
"properties": {
"limitKb": {
"type": "number",
"minimum": 1,
"description": "Hard limit in kilobytes (gzip). Above this the route is flagged as exceeding budget; CI emits a warning comment but does not fail."
},
"warningKb": {
"type": "number",
"minimum": 1,
"description": "Warning threshold in kilobytes (gzip). Above this and at-or-below limitKb, route is flagged as approaching budget."
}
}
}
}
}
@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from "vitest";
import { parsePurgeArgs, purgeBunnyCache, resolveAbsolutePurgeUrl } from "./bunny-purge";
describe("parsePurgeArgs", () => {
it("returns defaults for an empty argv", () => {
expect(parsePurgeArgs([])).toEqual({ paths: [], dryRun: false });
});
it("parses --dry-run", () => {
expect(parsePurgeArgs(["--dry-run"])).toEqual({ paths: [], dryRun: true });
});
it("parses a single --paths value", () => {
expect(parsePurgeArgs(["--paths", "/assets/app.js"])).toEqual({
paths: ["/assets/app.js"],
dryRun: false,
});
});
it("splits comma-separated --paths into multiple entries", () => {
expect(parsePurgeArgs(["--paths", "/a, /b,/c"])).toEqual({
paths: ["/a", "/b", "/c"],
dryRun: false,
});
});
it("accepts repeated --paths flags", () => {
expect(parsePurgeArgs(["--paths", "/a", "--paths", "/b"])).toEqual({
paths: ["/a", "/b"],
dryRun: false,
});
});
it("treats --path as an alias for --paths", () => {
expect(parsePurgeArgs(["--path", "/x"])).toEqual({ paths: ["/x"], dryRun: false });
});
it("throws on an unknown argument", () => {
expect(() => parsePurgeArgs(["--unknown"])).toThrow(/Unknown argument/);
});
it("throws when --paths is supplied without a value", () => {
expect(() => parsePurgeArgs(["--paths"])).toThrow(/expects a value/);
});
});
describe("resolveAbsolutePurgeUrl", () => {
it("returns the input unchanged when already absolute", () => {
expect(resolveAbsolutePurgeUrl("https://cdn.example.com/x.js")).toBe("https://cdn.example.com/x.js");
});
it("joins relative paths against the CDN base", () => {
expect(resolveAbsolutePurgeUrl("/assets/app.js", "https://cdn.example.com")).toBe(
"https://cdn.example.com/assets/app.js"
);
});
it("throws when relative paths are passed without a CDN base", () => {
expect(() => resolveAbsolutePurgeUrl("/assets/app.js")).toThrow(/relative/);
});
it("rejects empty paths", () => {
expect(() => resolveAbsolutePurgeUrl(" ")).toThrow(/Empty path/);
});
});
describe("purgeBunnyCache", () => {
function makeOkFetch() {
return vi.fn(async () => Promise.resolve({ ok: true, status: 200 } as Response));
}
it("calls the whole-zone purge endpoint by default", async () => {
const fetchImpl = makeOkFetch();
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = fetchImpl.mock.calls[0];
expect(String(url)).toBe("https://api.bunny.net/pullzone/12345/purgeCache");
expect(init?.method).toBe("POST");
expect((init?.headers as Record<string, string>).AccessKey).toBe("secret");
expect(result).toEqual({
url: "https://api.bunny.net/pullzone/12345/purgeCache",
requests: 1,
ok: true,
failedStatus: undefined,
});
});
it("calls /purge?url=... once per path when paths are supplied", async () => {
const fetchImpl = makeOkFetch();
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
paths: ["/assets/a.js", "/assets/b.js"],
cdnBaseUrl: "https://cdn.example.com",
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.bunny.net/purge?url=https%3A%2F%2Fcdn.example.com%2Fassets%2Fa.js"
);
expect(String(fetchImpl.mock.calls[1][0])).toBe(
"https://api.bunny.net/purge?url=https%3A%2F%2Fcdn.example.com%2Fassets%2Fb.js"
);
expect(result.ok).toBe(true);
expect(result.requests).toBe(2);
});
it("surfaces the first non-2xx status as a failure", async () => {
const fetchImpl = vi.fn(async () => Promise.resolve({ ok: false, status: 403 } as Response));
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
fetchImpl,
});
expect(result.ok).toBe(false);
expect(result.failedStatus).toBe(403);
});
it("stops iterating paths after the first failure and reports the failed status", async () => {
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls += 1;
if (calls === 2) return { ok: false, status: 502 } as Response;
return { ok: true, status: 200 } as Response;
});
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
paths: ["/a", "/b", "/c"],
cdnBaseUrl: "https://cdn.example.com",
fetchImpl,
});
expect(result.ok).toBe(false);
expect(result.failedStatus).toBe(502);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("throws when pullZoneId is missing", async () => {
await expect(purgeBunnyCache({ pullZoneId: "", apiKey: "x", fetchImpl: makeOkFetch() })).rejects.toThrow(
/BUNNY_PULL_ZONE_ID/
);
});
it("throws when apiKey is missing", async () => {
await expect(purgeBunnyCache({ pullZoneId: "1", apiKey: "", fetchImpl: makeOkFetch() })).rejects.toThrow(
/BUNNY_API_KEY/
);
});
});
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env -S node --import tsx
/**
* scripts/cdn/bunny-purge.ts
*
* Cache-purge helper for the Bunny.net pull-zone fronting our static assets.
* Used as a post-deploy step so hash-named bundles published in the new build
* are visible at the edge immediately (otherwise users hit stale `index.html`
* referencing assets the edge hasn't pulled yet).
*
* Usage:
* pnpm exec tsx scripts/cdn/bunny-purge.ts # purge entire pull zone
* pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/x.js # purge specific URL(s)
* pnpm exec tsx scripts/cdn/bunny-purge.ts --dry-run # print plan, no API call
*
* Required env (provisioned in CI secrets — see docs/ops/asset-cdn.md):
* BUNNY_PULL_ZONE_ID — numeric id of the Bunny pull zone
* BUNNY_API_KEY — account-level API key (NOT the read-only password)
* ASSET_CDN_URL — base URL of the CDN (required for path-scoped purges)
*
* Exit codes:
* 0 — purge succeeded (or skipped because the credentials are intentionally absent)
* 1 — API returned a non-2xx status, or arguments were invalid
*
* Refs: docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md
*/
const BUNNY_API_ROOT = "https://api.bunny.net";
export interface BunnyPurgeOptions {
pullZoneId: string;
apiKey: string;
/** Optional. When set, purges only the listed absolute URLs instead of the whole zone. */
paths?: readonly string[];
/** Optional. Base CDN URL used to expand relative `--paths` arguments. */
cdnBaseUrl?: string;
/** Optional fetch implementation — overridable for tests. Defaults to global fetch. */
fetchImpl?: typeof fetch;
}
export interface BunnyPurgeResult {
/** Final URL hit (zone purge URL or last per-path purge URL). */
url: string;
/** Number of HTTP requests made. */
requests: number;
/** Whether all calls returned a 2xx status. */
ok: boolean;
/** When `ok === false`, the first non-2xx response status; undefined otherwise. */
failedStatus?: number;
}
/**
* Resolve a `--paths` value (possibly relative like `/assets/app.js`) to an
* absolute URL against the CDN base. The Bunny `purgeUrl` endpoint requires
* a fully-qualified URL.
*/
export function resolveAbsolutePurgeUrl(rawPath: string, cdnBaseUrl?: string): string {
const trimmed = rawPath.trim();
if (!trimmed) throw new Error("Empty path supplied to --paths");
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (!cdnBaseUrl) {
throw new Error(`Path "${trimmed}" is relative but ASSET_CDN_URL is not set; cannot build absolute URL.`);
}
return new URL(trimmed, cdnBaseUrl).toString();
}
/**
* Parse argv after the script name. Supports:
* --paths a,b,c comma-separated list
* --paths a --paths b repeated flag
* --dry-run no API call
*/
export function parsePurgeArgs(argv: readonly string[]): { paths: string[]; dryRun: boolean } {
const paths: string[] = [];
let dryRun = false;
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--dry-run") {
dryRun = true;
continue;
}
if (token === "--paths" || token === "--path") {
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
throw new Error(`${token} expects a value`);
}
for (const part of next.split(",")) {
const trimmed = part.trim();
if (trimmed) paths.push(trimmed);
}
index += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
return { paths, dryRun };
}
export async function purgeBunnyCache(options: BunnyPurgeOptions): Promise<BunnyPurgeResult> {
const fetchImpl = options.fetchImpl ?? fetch;
if (!options.pullZoneId) throw new Error("BUNNY_PULL_ZONE_ID is required");
if (!options.apiKey) throw new Error("BUNNY_API_KEY is required");
const headers = {
AccessKey: options.apiKey,
Accept: "application/json",
};
// Per-path purge — preferred when only a handful of files changed
// (avoids cold-cache penalty on the rest of the zone).
if (options.paths && options.paths.length > 0) {
let lastUrl = "";
for (const rawPath of options.paths) {
const absolute = resolveAbsolutePurgeUrl(rawPath, options.cdnBaseUrl);
const purgeUrl = `${BUNNY_API_ROOT}/purge?url=${encodeURIComponent(absolute)}`;
// eslint-disable-next-line no-await-in-loop
const response = await fetchImpl(purgeUrl, { method: "POST", headers });
lastUrl = purgeUrl;
if (!response.ok) {
return { url: purgeUrl, requests: options.paths.indexOf(rawPath) + 1, ok: false, failedStatus: response.status };
}
}
return { url: lastUrl, requests: options.paths.length, ok: true };
}
// Whole-zone purge — used by the default post-deploy step. Bunny accepts
// POST with no body; the AccessKey header authenticates the call.
const zoneUrl = `${BUNNY_API_ROOT}/pullzone/${encodeURIComponent(options.pullZoneId)}/purgeCache`;
const response = await fetchImpl(zoneUrl, { method: "POST", headers });
return {
url: zoneUrl,
requests: 1,
ok: response.ok,
failedStatus: response.ok ? undefined : response.status,
};
}
async function main(): Promise<number> {
const pullZoneId = process.env.BUNNY_PULL_ZONE_ID?.trim();
const apiKey = process.env.BUNNY_API_KEY?.trim();
const cdnBaseUrl = process.env.ASSET_CDN_URL?.trim();
if (!pullZoneId || !apiKey) {
// Intentional no-op so deploy workflows can wire this step unconditionally:
// when the operator has not yet provisioned the Bunny zone, the step
// logs and exits 0 instead of failing the deploy. See docs/ops/asset-cdn.md.
// eslint-disable-next-line no-console
console.log("[bunny-purge] BUNNY_PULL_ZONE_ID or BUNNY_API_KEY unset — skipping (no-op).");
return 0;
}
const args = parsePurgeArgs(process.argv.slice(2));
if (args.dryRun) {
// eslint-disable-next-line no-console
console.log(
JSON.stringify(
{
dryRun: true,
pullZoneId,
paths: args.paths,
target: args.paths.length > 0 ? "per-path" : "whole-zone",
},
null,
2
)
);
return 0;
}
const result = await purgeBunnyCache({
pullZoneId,
apiKey,
paths: args.paths.length > 0 ? args.paths : undefined,
cdnBaseUrl,
});
if (result.ok) {
// eslint-disable-next-line no-console
console.log(`[bunny-purge] success (${result.requests} request(s))`);
return 0;
}
// eslint-disable-next-line no-console
console.error(`[bunny-purge] failed: HTTP ${result.failedStatus} at ${result.url}`);
return 1;
}
// `import.meta.url` ends with this file path when invoked directly; the
// vitest runner imports the module instead and skips the side-effect.
const invokedDirectly =
typeof process !== "undefined" &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
main()
.then((code) => {
process.exit(code);
})
.catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error("[bunny-purge] unexpected error:", error);
process.exit(1);
});
}
@@ -0,0 +1,108 @@
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
import { renderApiInventoryGuardResult, validateApiInventory } from "./check-api-inventory.js";
const execFileAsync = promisify(execFile);
async function makeFixture() {
const root = await mkdtemp(path.join(os.tmpdir(), "api-inventory-"));
const appSourcePath = path.join(root, "app.ts");
const removedWebApiRoutesRoot = path.join(root, "routes", "api");
await writeFile(appSourcePath, 'import { Hono } from "hono";\nconst app = new Hono();\napp.get("/healthz", (c) => c.json({ ok: true }));\n', "utf8");
return { root, appSourcePath, removedWebApiRoutesRoot };
}
describe("api inventory guard", () => {
test("passes when current inventory has no deprecated route keys", async () => {
const result = await validateApiInventory();
expect(result).toEqual({ ok: true, violations: [], missingRoots: [] });
expect(renderApiInventoryGuardResult(result)).toMatch(/^PASS api-inventory/);
});
test("fails on denylisted Hono route keys without printing handler source", async () => {
const fixture = await makeFixture();
await writeFile(
fixture.appSourcePath,
'import { Hono } from "hono";\nconst app = new Hono();\napp.get("/v1/auth/me", () => {\n const token = "SECRET_TOKEN_SHOULD_NOT_PRINT";\n return token;\n});\n',
"utf8",
);
const result = await validateApiInventory({ appSourcePath: fixture.appSourcePath, removedWebApiRoutesRoot: fixture.removedWebApiRoutesRoot });
const output = renderApiInventoryGuardResult(result);
expect(result.ok).toBe(false);
expect(output).toContain("GET /v1/auth/me");
expect(output).toContain(fixture.appSourcePath);
expect(output).toContain(":3:1");
expect(output).not.toContain("SECRET_TOKEN_SHOULD_NOT_PRINT");
});
test("fails when removed React Router API route files are present", async () => {
const fixture = await makeFixture();
await mkdir(path.join(fixture.removedWebApiRoutesRoot, "auth"), { recursive: true });
await writeFile(path.join(fixture.removedWebApiRoutesRoot, "auth", "verify-token.tsx"), "export async function loader() {}\n", "utf8");
const result = await validateApiInventory({ appSourcePath: fixture.appSourcePath, removedWebApiRoutesRoot: fixture.removedWebApiRoutesRoot });
const output = renderApiInventoryGuardResult(result);
expect(result.ok).toBe(false);
expect(output).toContain("GET /v1/auth/verify-token");
expect(output).toContain("removed-react-router-api");
expect(output).toContain(path.join(fixture.removedWebApiRoutesRoot, "auth", "verify-token.tsx"));
});
test("blocks unrelated removed web API runtime routes too", async () => {
const fixture = await makeFixture();
await mkdir(path.join(fixture.removedWebApiRoutesRoot, "entities"), { recursive: true });
await writeFile(path.join(fixture.removedWebApiRoutesRoot, "entities", "index.tsx"), "export async function loader() {}\n", "utf8");
const result = await validateApiInventory({ appSourcePath: fixture.appSourcePath, removedWebApiRoutesRoot: fixture.removedWebApiRoutesRoot });
expect(result.ok).toBe(false);
expect(result.violations).toEqual([
expect.objectContaining({
routeKey: "REMOVED /api web route",
surface: "removed-react-router-api",
sourcePath: path.join(fixture.removedWebApiRoutesRoot, "entities", "index.tsx"),
}),
]);
});
test("passes when the removed web API route root is absent", async () => {
const fixture = await makeFixture();
const missingRemovedRoutesRoot = path.join(fixture.root, "missing", "api");
const result = await validateApiInventory({ appSourcePath: fixture.appSourcePath, removedWebApiRoutesRoot: missingRemovedRoutesRoot });
expect(result).toEqual({ ok: true, violations: [], missingRoots: [] });
});
test("CLI fails on unknown flags", async () => {
await expect(execFileAsync("pnpm", ["exec", "tsx", "scripts/check-api-inventory.ts", "--unknown"])).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("FAIL api-inventory: unknown arguments"),
});
});
test("diagnostics are route-key and location based", async () => {
const fixture = await makeFixture();
await writeFile(
fixture.appSourcePath,
'const app = { get() {} };\napp.get("/v1/auth/me", () => "PASSWORD=never-print");\n',
"utf8",
);
const result = await validateApiInventory({ appSourcePath: fixture.appSourcePath, removedWebApiRoutesRoot: fixture.removedWebApiRoutesRoot });
const output = renderApiInventoryGuardResult(result);
expect(output).toMatch(/GET \/v1\/auth\/me \[hono\] .+app\.ts:2:1/);
expect(output).not.toContain("PASSWORD=never-print");
expect(output).not.toContain("handler");
});
});
@@ -0,0 +1,170 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { scanApiRoutesFromFile, type ApiRouteKey } from "../tools/api-inventory/scan-api-routes.js";
export const DEPRECATED_API_ROUTE_KEYS = ["GET /v1/auth/verify-token", "GET /v1/auth/me"] as const satisfies readonly ApiRouteKey[];
export type DeprecatedApiRouteKey = (typeof DEPRECATED_API_ROUTE_KEYS)[number];
export type ApiInventoryGuardOptions = {
appSourcePath?: string;
removedWebApiRoutesRoot?: string;
legacyRoutesRoot?: string;
};
export type ApiInventoryViolation = {
routeKey: DeprecatedApiRouteKey | "REMOVED /api web route" | "UNRESOLVED mounted router";
surface: "hono" | "removed-react-router-api" | "hono-route-mount";
sourcePath: string;
location?: {
line: number;
column: number;
};
detail?: string;
};
export type ApiInventoryGuardResult = {
ok: boolean;
violations: ApiInventoryViolation[];
missingRoots: string[];
};
const DEFAULT_APP_SOURCE_PATH = "apps/api/src/app.ts";
const DEFAULT_REMOVED_WEB_API_ROUTES_ROOT = "apps/web/app/routes/api";
const deprecatedRouteKeys = new Set<ApiRouteKey>(DEPRECATED_API_ROUTE_KEYS);
const legacyRouteFileNamesByRouteKey = new Map<DeprecatedApiRouteKey, readonly string[]>([
["GET /v1/auth/verify-token", [path.join("auth", "verify-token.ts"), path.join("auth", "verify-token.tsx")]],
["GET /v1/auth/me", [path.join("auth", "me.ts"), path.join("auth", "me.tsx")]],
]);
export async function validateApiInventory(options: ApiInventoryGuardOptions = {}): Promise<ApiInventoryGuardResult> {
const appSourcePath = options.appSourcePath ?? DEFAULT_APP_SOURCE_PATH;
const removedWebApiRoutesRoot = options.removedWebApiRoutesRoot ?? options.legacyRoutesRoot ?? DEFAULT_REMOVED_WEB_API_ROUTES_ROOT;
const violations: ApiInventoryViolation[] = [];
const missingRoots: string[] = [];
const scanResult = await scanApiRoutesFromFile(appSourcePath);
for (const mountedRouter of scanResult.unresolvedMountedRouters) {
violations.push({
routeKey: "UNRESOLVED mounted router",
surface: "hono-route-mount",
sourcePath: appSourcePath,
location: mountedRouter.location,
detail: `${mountedRouter.prefix} -> ${mountedRouter.receiver}`,
});
}
for (const route of scanResult.routes) {
if (!deprecatedRouteKeys.has(route.key)) continue;
violations.push({
routeKey: route.key as DeprecatedApiRouteKey,
surface: "hono",
sourcePath: appSourcePath,
location: route.location,
});
}
const removedWebApiRootExists = await pathExists(removedWebApiRoutesRoot);
const removedWebApiRouteFiles = removedWebApiRootExists ? await listFiles(removedWebApiRoutesRoot) : [];
const normalizedRemovedWebApiRouteFiles = new Set(removedWebApiRouteFiles.map((filePath) => normalizeRouteFilePath(filePath)));
for (const fileName of removedWebApiRouteFiles) {
violations.push({
routeKey: "REMOVED /api web route",
surface: "removed-react-router-api",
sourcePath: path.join(removedWebApiRoutesRoot, fileName),
});
}
for (const [routeKey, fileNames] of legacyRouteFileNamesByRouteKey.entries()) {
for (const fileName of fileNames) {
if (!normalizedRemovedWebApiRouteFiles.has(normalizeRouteFilePath(fileName))) continue;
violations.push({
routeKey,
surface: "removed-react-router-api",
sourcePath: path.join(removedWebApiRoutesRoot, fileName),
});
}
}
return { ok: violations.length === 0 && missingRoots.length === 0, violations, missingRoots };
}
export function renderApiInventoryGuardResult(result: ApiInventoryGuardResult): string {
if (result.ok) return "PASS api-inventory: no deprecated route keys found";
return [
"FAIL api-inventory: deprecated route keys or removed web API routes found",
...result.missingRoots.map((root) => `- missing scan root: ${root}`),
...result.violations.map(formatViolation),
].join("\n");
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch (error) {
if (isNodeError(error) && error.code === "ENOENT") return false;
throw error;
}
}
async function listFiles(root: string, relativeDir = ""): Promise<string[]> {
const absoluteDir = path.join(root, relativeDir);
const entries = await readdir(absoluteDir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const relativePath = path.join(relativeDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listFiles(root, relativePath)));
} else if (entry.isFile()) {
files.push(relativePath);
}
}
return files;
}
function formatViolation(violation: ApiInventoryViolation): string {
const location = violation.location ? `:${violation.location.line}:${violation.location.column}` : "";
const detail = violation.detail ? ` (${violation.detail})` : "";
return `- ${violation.routeKey} [${violation.surface}] ${violation.sourcePath}${location}${detail}`;
}
function normalizeRouteFilePath(filePath: string): string {
return filePath.split(path.sep).join("/");
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return typeof error === "object" && error !== null && "code" in error;
}
async function main(args = process.argv.slice(2)): Promise<void> {
if (args.length > 0) {
console.error("FAIL api-inventory: unknown arguments");
for (const arg of args) console.error(`- ${arg}`);
process.exitCode = 1;
return;
}
const result = await validateApiInventory();
const output = renderApiInventoryGuardResult(result);
if (result.ok) {
console.log(output);
return;
}
console.error(output);
process.exitCode = 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
await main();
}
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { envVariableDefinitions } from "../packages/config/src/catalog.ts";
const ENV_FILES = [".env.example", ".env.local.example", ".env.production.example", ".env.fly.example", ".env.test"];
const SHARED_REQUIRED_FILES = [".env.example", ".env.test"];
const PRODUCTION_EXAMPLE_FILES = [".env.production.example", ".env.fly.example"];
const PRODUCTION_PLACEHOLDER_PATTERN = /(^|[.:/@-])(?:app\.|admin\.|api\.)?example\.(?:com|org|net)(?:$|[/:?&.#-])/i;
const KEY_PATTERN = /(?:^|\s)([A-Z][A-Z0-9_]*)(?:\s*=|=)/g;
function readTextFile(rootDir, relativePath) {
return fs.readFileSync(path.join(rootDir, relativePath), "utf8");
}
function normalizeLine(rawLine) {
return rawLine.trim().replace(/\\$/, "").trim();
}
function isCommentedAssignment(line) {
return /^#\s*[A-Z][A-Z0-9_]*\s*=/.test(line);
}
function stripInlineComment(value) {
let quote = null;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
const previous = value[index - 1];
if ((character === '"' || character === "'") && previous !== "\\") {
quote = quote === character ? null : quote ?? character;
continue;
}
if (character === "#" && quote === null && /\s/.test(previous ?? " ")) {
return value.slice(0, index).trim();
}
}
return value.trim();
}
function unquoteValue(value) {
const trimmed = value.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseEnvFile(rootDir, relativePath) {
const entries = [];
const duplicateKeys = [];
const seen = new Set();
const content = readTextFile(rootDir, relativePath);
const lines = content.split(/\r?\n/);
lines.forEach((rawLine, index) => {
let line = normalizeLine(rawLine);
if (line.startsWith("flyctl secrets set ")) {
line = line.replace(/^flyctl secrets set\s+/, "");
}
if (line.length === 0 || line.startsWith("#") || line === "--app YOUR_APP_NAME") {
return;
}
KEY_PATTERN.lastIndex = 0;
for (const match of line.matchAll(KEY_PATTERN)) {
const key = match[1];
const valueStart = match.index + match[0].lastIndexOf("=") + 1;
const value = unquoteValue(stripInlineComment(line.slice(valueStart)));
const entry = { key, value, line: index + 1 };
entries.push(entry);
if (relativePath !== ".env.fly.example") {
if (seen.has(key)) {
duplicateKeys.push(entry);
} else {
seen.add(key);
}
}
}
});
return { entries, duplicateKeys, commentedKeys: parseCommentedKeys(content) };
}
function parseCommentedKeys(content) {
const keys = new Set();
content.split(/\r?\n/).forEach((rawLine) => {
const line = rawLine.trim();
if (!isCommentedAssignment(line)) {
return;
}
const match = /^#\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
if (match) {
keys.add(match[1]);
}
});
return keys;
}
function buildCatalog() {
const staticNames = new Set();
const dynamicPrefixes = [];
const requiredNames = new Set();
for (const definition of envVariableDefinitions) {
if (definition.dynamicPrefix) {
dynamicPrefixes.push(definition.dynamicPrefix);
} else {
staticNames.add(definition.name);
}
if (definition.requiredInProduction) {
requiredNames.add(definition.name);
}
}
return { staticNames, dynamicPrefixes, requiredNames };
}
function isCatalogedKey(key, catalog) {
return catalog.staticNames.has(key) || catalog.dynamicPrefixes.some((prefix) => key.startsWith(prefix));
}
function checkEnvFile(rootDir, relativePath, catalog) {
const passes = [];
const failures = [];
const { entries, duplicateKeys, commentedKeys } = parseEnvFile(rootDir, relativePath);
const activeKeys = new Set(entries.map((entry) => entry.key));
for (const entry of entries) {
if (!isCatalogedKey(entry.key, catalog)) {
failures.push(`${relativePath}:${entry.line}: unknown-key ${entry.key}`);
}
}
for (const duplicate of duplicateKeys) {
failures.push(`${relativePath}:${duplicate.line}: duplicate-key ${duplicate.key}`);
}
if ((relativePath === ".env.production.example" || relativePath === ".env.fly.example") && activeKeys.has("REDIS_URL")) {
const redisEntry = entries.find((entry) => entry.key === "REDIS_URL");
failures.push(`${relativePath}:${redisEntry?.line ?? 1}: redis-url-must-remain-commented REDIS_URL`);
}
if (relativePath === ".env.test" || PRODUCTION_EXAMPLE_FILES.includes(relativePath)) {
for (const entry of entries) {
if (PRODUCTION_PLACEHOLDER_PATTERN.test(entry.value) && !entry.value.endsWith("@example.com")) {
failures.push(`${relativePath}:${entry.line}: production-domain-placeholder ${entry.key}`);
}
}
}
for (const requiredName of catalog.requiredNames) {
if ((SHARED_REQUIRED_FILES.includes(relativePath) || PRODUCTION_EXAMPLE_FILES.includes(relativePath)) && !activeKeys.has(requiredName) && !commentedKeys.has(requiredName)) {
failures.push(`${relativePath}: missing-required-key ${requiredName}`);
}
}
if (failures.length === 0) {
passes.push(`${relativePath}: ${activeKeys.size} active keys match catalog`);
}
return { passes, failures, observations: [] };
}
export function checkEnvDrift(rootDir = process.cwd()) {
const catalog = buildCatalog();
const result = { passes: [], failures: [], observations: [] };
for (const relativePath of ENV_FILES) {
if (!fs.existsSync(path.join(rootDir, relativePath))) {
result.failures.push(`${relativePath}: missing-env-file`);
continue;
}
const check = checkEnvFile(rootDir, relativePath, catalog);
result.passes.push(...check.passes);
result.failures.push(...check.failures);
result.observations.push(...check.observations);
}
return result;
}
export function formatEnvDriftResult(result) {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
return [
`${status} env drift verification`,
...result.passes.map((pass) => `PASS ${pass}`),
...result.observations.map((observation) => `OBSERVE ${observation}`),
...result.failures.map((failure) => `FAIL ${failure}`),
`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`,
"",
].join("\n");
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const result = checkEnvDrift(process.cwd());
const output = formatEnvDriftResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
}
@@ -0,0 +1,214 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { afterEach, describe, it } from "vitest";
import { checkEnvDrift, formatEnvDriftResult } from "./check-env-drift.mjs";
const fixtures = [];
const ENV_FIXTURES = {
".env.example": `# Core
APP_NAME=ToolCTRL
DATABASE_URL="postgres://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
SESSION_SECRET=
CRYPTO_SECRET=
APP_BASE_DOMAIN=localhost
WEB_APP_HOST=app.localhost
ADMIN_APP_HOST=admin.localhost
API_HOST=api.localhost
# APP_PROTOCOL=http
API_ACCESS_TOKEN=
JWT_SECRET=
# REDIS_URL is a fallback only when BULLMQ_REDIS_URL is absent; keep it commented if a platform injects it.
# REDIS_URL=redis://localhost:6379/0
SITE_KEY_EXAMPLE_SITE=
`,
".env.local.example": `APP_NAME=ToolCTRL Local
DATABASE_URL="file:./dev.db?connection_limit=1&connect_timeout=30"
SESSION_SECRET=replace-with-local-session-secret
CRYPTO_SECRET=replace-with-local-crypto-secret
APP_BASE_DOMAIN=localhost:3000
WEB_APP_HOST=app.localhost:3000
ADMIN_APP_HOST=admin.localhost:3000
API_HOST=api.localhost:3001
APP_PROTOCOL=http
API_ACCESS_TOKEN=replace-with-local-api-token
JWT_SECRET=replace-with-local-jwt-secret
SITE_KEY_LOCAL=replace-with-local-site-key
`,
".env.production.example": `APP_NAME=ToolCTRL
DATABASE_URL="postgres://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
SESSION_SECRET=
CRYPTO_SECRET=
APP_BASE_DOMAIN=your-domain.invalid
WEB_APP_HOST=app.your-domain.invalid
ADMIN_APP_HOST=admin.your-domain.invalid
API_HOST=api.your-domain.invalid
APP_PROTOCOL=https
API_ACCESS_TOKEN=
JWT_SECRET=
BULLMQ_REDIS_URL=redis://localhost:6379/0
# REDIS_URL=redis://localhost:6379/0
`,
".env.fly.example": `flyctl secrets set APP_NAME=YOUR_APP_NAME \\
DATABASE_URL=postgres://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE} \\
SESSION_SECRET=<session-secret> \\
CRYPTO_SECRET=<crypto-secret> \\
API_ACCESS_TOKEN=<api-access-token> \\
JWT_SECRET=<jwt-secret> \\
APP_BASE_DOMAIN=your-domain.invalid \\
WEB_APP_HOST=app.your-domain.invalid \\
ADMIN_APP_HOST=admin.your-domain.invalid \\
API_HOST=api.your-domain.invalid \\
APP_PROTOCOL=https \\
BULLMQ_REDIS_URL=redis://default:<redis-password>@redis.your-domain.invalid:6379/0 \\
--app YOUR_APP_NAME
# REDIS_URL=redis://default:<redis-password>@redis.your-domain.invalid:6379/0
`,
".env.test": `APP_NAME=ToolCTRL Test
DATABASE_URL="file:./test.db?connection_limit=1&connect_timeout=30"
SESSION_SECRET=test-session-secret-placeholder
CRYPTO_SECRET=test-crypto-secret-placeholder
APP_BASE_DOMAIN=localhost.test
WEB_APP_HOST=app.localhost.test
ADMIN_APP_HOST=admin.localhost.test
API_HOST=api.localhost.test
APP_PROTOCOL=http
API_ACCESS_TOKEN=test-api-access-token-placeholder
JWT_SECRET=test-jwt-secret-placeholder
SITE_KEY_EXAMPLE_SITE=test-site-key-placeholder
`,
};
function writeFile(rootDir, relativePath, content) {
const absolutePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, content);
}
function createFixture(overrides = {}) {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-drift-"));
fixtures.push(rootDir);
for (const [relativePath, content] of Object.entries(ENV_FIXTURES)) {
writeFile(rootDir, relativePath, overrides[relativePath] ?? content);
}
return rootDir;
}
afterEach(() => {
while (fixtures.length > 0) {
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
}
});
describe("check-env-drift", () => {
it("prints PASS output for cataloged env fixtures", () => {
const rootDir = createFixture();
const result = checkEnvDrift(rootDir);
const output = formatEnvDriftResult(result);
assert.deepEqual(result.failures, []);
assert.match(output, /^PASS env drift verification/);
assert.match(output, /PASS \.env\.test: \d+ active keys match catalog/);
assert.match(output, /Summary: \d+ passed, 0 observed, 0 failed/);
});
it("fails for unknown keys", () => {
const rootDir = createFixture({
".env.test": `${ENV_FIXTURES[".env.test"]}UNTRACKED_SECRET=super-secret-value
`,
});
const result = checkEnvDrift(rootDir);
assert.match(result.failures.join("\n"), /\.env\.test:\d+: unknown-key UNTRACKED_SECRET/);
});
it("fails for duplicate keys within one file", () => {
const rootDir = createFixture({
".env.local.example": `${ENV_FIXTURES[".env.local.example"]}APP_NAME=Duplicate
`,
});
const result = checkEnvDrift(rootDir);
assert.match(result.failures.join("\n"), /\.env\.local\.example:\d+: duplicate-key APP_NAME/);
});
it("allows dynamic SITE_KEY-prefixed variables", () => {
const rootDir = createFixture({
".env.test": `${ENV_FIXTURES[".env.test"]}SITE_KEY_CUSTOMER_PORTAL=test-site-key-placeholder
`,
});
const result = checkEnvDrift(rootDir);
assert.deepEqual(result.failures, []);
});
it("fails when production example files keep example.com placeholders", () => {
const rootDir = createFixture({
".env.production.example": ENV_FIXTURES[".env.production.example"].replaceAll("your-domain.invalid", "example.com"),
});
const result = checkEnvDrift(rootDir);
assert.match(result.failures.join("\n"), /\.env\.production\.example:\d+: production-domain-placeholder APP_BASE_DOMAIN/);
});
it("requires production keys in fly secrets examples", () => {
const rootDir = createFixture({
".env.fly.example": ENV_FIXTURES[".env.fly.example"].replace(/JWT_SECRET=<jwt-secret> \\\n/, ""),
});
const result = checkEnvDrift(rootDir);
assert.match(result.failures.join("\n"), /\.env\.fly\.example: missing-required-key JWT_SECRET/);
});
it("redacts values from diagnostics", () => {
const leakedValue = "top-secret-token-value";
const rootDir = createFixture({
".env.test": `${ENV_FIXTURES[".env.test"]}UNTRACKED_SECRET=${leakedValue}
`,
});
const result = checkEnvDrift(rootDir);
const output = formatEnvDriftResult(result);
assert.match(output, /unknown-key UNTRACKED_SECRET/);
assert.doesNotMatch(output, new RegExp(leakedValue));
});
it("CLI exits non-zero without leaking values", () => {
const leakedValue = "super-secret-cli-value";
const rootDir = createFixture({
".env.test": `${ENV_FIXTURES[".env.test"]}UNTRACKED_SECRET=${leakedValue}
`,
});
const scriptPath = path.resolve("scripts/check-env-drift.mjs");
const nodePath = path.resolve("node_modules");
assert.throws(
() => execFileSync(process.execPath, ["--import", path.join(nodePath, "tsx/dist/loader.mjs"), scriptPath], {
cwd: rootDir,
encoding: "utf8",
stdio: "pipe",
}),
(error) => {
assert.equal(error.status, 1);
const output = `${error.stdout}${error.stderr}`;
assert.match(output, /FAIL env drift verification/);
assert.match(output, /unknown-key UNTRACKED_SECRET/);
assert.doesNotMatch(output, new RegExp(leakedValue));
return true;
},
);
});
});
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Deterministic cosign installer for CI deploy jobs.
set -euo pipefail
COSIGN_VERSION="${COSIGN_VERSION:-v3.0.6}"
COSIGN_SHA256="${COSIGN_SHA256:-c956e5dfcac53d52bcf058360d579472f0c1d2d9b69f55209e256fe7783f4c74}"
COSIGN_URL="${COSIGN_URL:-https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64}"
COSIGN_INSTALL_PATH="${COSIGN_INSTALL_PATH:-/usr/local/bin/cosign}"
if command -v cosign >/dev/null 2>&1; then
cosign version
exit 0
fi
tmp_file="$(mktemp)"
cleanup() {
rm -f "$tmp_file"
}
trap cleanup EXIT
curl --fail --silent --show-error --location "$COSIGN_URL" --output "$tmp_file"
printf '%s %s\n' "$COSIGN_SHA256" "$tmp_file" | sha256sum --check --status
install -m 0755 "$tmp_file" "$COSIGN_INSTALL_PATH"
cosign version
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Sentry release helper used by GitHub + Gitea CI to tag releases,
# upload source-maps, correlate commits and mark deploys against our
# self-hosted Sentry (https://sentry.boiler.saas-ctrl.com).
#
# Usage:
# scripts/ci/sentry-release.sh \
# --project <name> \
# --environment <env> \
# --release <git-sha> \
# --sourcemaps <path-or-"none">
#
# Required environment variables:
# SENTRY_AUTH_TOKEN Auth token scoped to project:releases
# SENTRY_ORG Sentry organisation slug (e.g. "sentry")
# SENTRY_URL Base URL of the self-hosted instance
#
# This script intentionally never prints the auth token. Callers in CI
# should mask SENTRY_AUTH_TOKEN via the runner's secret masking.
set -euo pipefail
PROJECT=""
ENVIRONMENT=""
RELEASE=""
SOURCEMAPS="none"
usage() {
cat <<USAGE >&2
sentry-release.sh: arguments
--project <name> Sentry project slug (required)
--environment <env> Deploy environment name (required)
--release <release> Release identifier (usually \$GIT_SHA, required)
--sourcemaps <path|none> Directory to upload source-maps from, or "none"
Required env vars: SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_URL
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--project)
PROJECT="${2:-}"
shift 2
;;
--environment)
ENVIRONMENT="${2:-}"
shift 2
;;
--release)
RELEASE="${2:-}"
shift 2
;;
--sourcemaps)
SOURCEMAPS="${2:-none}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "sentry-release.sh: unknown argument: $1" >&2
usage
exit 2
;;
esac
done
missing=()
[ -z "$PROJECT" ] && missing+=("--project")
[ -z "$ENVIRONMENT" ] && missing+=("--environment")
[ -z "$RELEASE" ] && missing+=("--release")
if [ "${#missing[@]}" -gt 0 ]; then
echo "sentry-release.sh: missing required argument(s): ${missing[*]}" >&2
usage
exit 2
fi
missing_env=()
[ -z "${SENTRY_AUTH_TOKEN:-}" ] && missing_env+=("SENTRY_AUTH_TOKEN")
[ -z "${SENTRY_ORG:-}" ] && missing_env+=("SENTRY_ORG")
[ -z "${SENTRY_URL:-}" ] && missing_env+=("SENTRY_URL")
if [ "${#missing_env[@]}" -gt 0 ]; then
echo "sentry-release.sh: missing required environment variable(s): ${missing_env[*]}" >&2
exit 3
fi
# Pinning the CLI version keeps CI deterministic; bump intentionally.
SENTRY_CLI_PACKAGE="${SENTRY_CLI_PACKAGE:-@sentry/cli@2.41.1}"
# Allow tests to inject a fake binary by overriding SENTRY_CLI.
if [ -n "${SENTRY_CLI:-}" ]; then
sentry_cli() {
"$SENTRY_CLI" "$@"
}
else
sentry_cli() {
npx --yes "$SENTRY_CLI_PACKAGE" "$@"
}
fi
export SENTRY_AUTH_TOKEN SENTRY_ORG SENTRY_URL
export SENTRY_PROJECT="$PROJECT"
# Disable shell-trace if a caller enabled it — protects the auth token
# from leaking into log surfaces.
set +x
echo "sentry-release: project=$PROJECT environment=$ENVIRONMENT release=$RELEASE sourcemaps=$SOURCEMAPS"
sentry_cli releases new "$RELEASE"
if [ "$SOURCEMAPS" != "none" ]; then
if [ ! -d "$SOURCEMAPS" ]; then
echo "sentry-release.sh: sourcemaps path does not exist: $SOURCEMAPS" >&2
exit 4
fi
sentry_cli sourcemaps upload --release "$RELEASE" "$SOURCEMAPS"
fi
sentry_cli releases set-commits "$RELEASE" --auto
sentry_cli releases finalize "$RELEASE"
sentry_cli releases deploys "$RELEASE" new -e "$ENVIRONMENT"
echo "sentry-release: done"
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# Bash test harness for scripts/ci/sentry-release.sh.
#
# Approach: replace sentry-cli with a stub via SENTRY_CLI=... env and
# assert the wrapper passes through the expected arguments to it.
# Each test runs in its own temp dir.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HELPER="$SCRIPT_DIR/sentry-release.sh"
PASS=0
FAIL=0
ok() {
echo " ok: $1"
PASS=$((PASS + 1))
}
fail() {
echo " FAIL: $1" >&2
FAIL=$((FAIL + 1))
}
run_test() {
local name="$1"
shift
echo "test: $name"
"$@"
}
make_stub() {
local logfile="$1"
local exitcode="${2:-0}"
local stubdir
stubdir="$(mktemp -d)"
cat <<STUB > "$stubdir/sentry-cli-stub"
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "$logfile"
exit $exitcode
STUB
chmod +x "$stubdir/sentry-cli-stub"
echo "$stubdir/sentry-cli-stub"
}
assert_contains() {
local logfile="$1"
local needle="$2"
if grep -Fq "$needle" "$logfile"; then
ok "log contains: $needle"
else
fail "log missing: $needle (log: $(cat "$logfile" | tr '\n' '|'))"
fi
}
assert_not_contains() {
local logfile="$1"
local needle="$2"
if grep -Fq "$needle" "$logfile"; then
fail "log unexpectedly contains: $needle"
else
ok "log absent: $needle"
fi
}
# ---------------------------------------------------------------
test_happy_path_with_sourcemaps() {
local tmp
tmp="$(mktemp -d)"
local log="$tmp/calls.log"
local stub
stub="$(make_stub "$log")"
local sourcemaps="$tmp/sm"
mkdir -p "$sourcemaps"
SENTRY_CLI="$stub" \
SENTRY_AUTH_TOKEN="t" SENTRY_ORG="sentry" SENTRY_URL="https://sentry.example" \
bash "$HELPER" \
--project boilerplate-web \
--environment production \
--release abc123 \
--sourcemaps "$sourcemaps" >/dev/null
assert_contains "$log" "releases new abc123"
assert_contains "$log" "sourcemaps upload --release abc123 $sourcemaps"
assert_contains "$log" "releases set-commits abc123 --auto"
assert_contains "$log" "releases finalize abc123"
assert_contains "$log" "releases deploys abc123 new -e production"
}
# ---------------------------------------------------------------
test_happy_path_without_sourcemaps() {
local tmp
tmp="$(mktemp -d)"
local log="$tmp/calls.log"
local stub
stub="$(make_stub "$log")"
SENTRY_CLI="$stub" \
SENTRY_AUTH_TOKEN="t" SENTRY_ORG="sentry" SENTRY_URL="https://sentry.example" \
bash "$HELPER" \
--project boilerplate-api \
--environment staging \
--release deadbeef \
--sourcemaps none >/dev/null
assert_contains "$log" "releases new deadbeef"
assert_not_contains "$log" "sourcemaps upload"
assert_contains "$log" "releases set-commits deadbeef --auto"
assert_contains "$log" "releases deploys deadbeef new -e staging"
}
# ---------------------------------------------------------------
test_missing_env_var_fails() {
local tmp
tmp="$(mktemp -d)"
local log="$tmp/calls.log"
local stub
stub="$(make_stub "$log")"
set +e
SENTRY_CLI="$stub" \
SENTRY_ORG="sentry" SENTRY_URL="https://sentry.example" \
bash "$HELPER" \
--project boilerplate-api \
--environment staging \
--release deadbeef \
--sourcemaps none >/dev/null 2>&1
local rc=$?
set -e
if [ "$rc" -ne 0 ]; then
ok "exits non-zero when SENTRY_AUTH_TOKEN missing (rc=$rc)"
else
fail "expected non-zero exit when SENTRY_AUTH_TOKEN missing"
fi
}
# ---------------------------------------------------------------
test_missing_required_arg_fails() {
local tmp
tmp="$(mktemp -d)"
local log="$tmp/calls.log"
local stub
stub="$(make_stub "$log")"
set +e
SENTRY_CLI="$stub" \
SENTRY_AUTH_TOKEN="t" SENTRY_ORG="sentry" SENTRY_URL="https://sentry.example" \
bash "$HELPER" \
--environment staging \
--release deadbeef \
--sourcemaps none >/dev/null 2>&1
local rc=$?
set -e
if [ "$rc" -ne 0 ]; then
ok "exits non-zero when --project missing (rc=$rc)"
else
fail "expected non-zero exit when --project missing"
fi
}
# ---------------------------------------------------------------
test_missing_sourcemap_dir_fails() {
local tmp
tmp="$(mktemp -d)"
local log="$tmp/calls.log"
local stub
stub="$(make_stub "$log")"
set +e
SENTRY_CLI="$stub" \
SENTRY_AUTH_TOKEN="t" SENTRY_ORG="sentry" SENTRY_URL="https://sentry.example" \
bash "$HELPER" \
--project boilerplate-web \
--environment production \
--release abc \
--sourcemaps "$tmp/does-not-exist" >/dev/null 2>&1
local rc=$?
set -e
if [ "$rc" -ne 0 ]; then
ok "exits non-zero when sourcemaps dir missing (rc=$rc)"
else
fail "expected non-zero exit when sourcemaps dir missing"
fi
}
run_test "happy-path with sourcemaps" test_happy_path_with_sourcemaps
run_test "happy-path without sourcemaps" test_happy_path_without_sourcemaps
run_test "missing env var fails" test_missing_env_var_fails
run_test "missing required arg fails" test_missing_required_arg_fails
run_test "missing sourcemap dir fails" test_missing_sourcemap_dir_fails
echo
echo "Summary: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
const [, , command, ...args] = process.argv;
const apiUrl = (process.env.SELECTED_COOLIFY_API_URL || process.env.COOLIFY_STAGING_API_URL || process.env.COOLIFY_API_URL || "").replace(/\/+$/, "");
const token = process.env.SELECTED_COOLIFY_TOKEN || process.env.COOLIFY_STAGING_TOKEN || process.env.COOLIFY_TOKEN || "";
const delayMs = Number(process.env.COOLIFY_DEPLOYMENT_POLL_MS || "5000");
const attempts = Number(process.env.COOLIFY_DEPLOYMENT_WAIT_ATTEMPTS || "90");
function fail(message) {
process.stderr.write(`${message}\n`);
process.exit(1);
}
async function coolify(path, options = {}) {
if (!apiUrl || !token) {
fail("Coolify API URL and token are required");
}
const response = await fetch(`${apiUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
...options.headers,
},
});
const text = await response.text();
let payload = {};
if (text.trim()) {
try {
payload = JSON.parse(text);
} catch {
payload = { message: text.trim() };
}
}
if (!response.ok) {
fail(`Coolify API request failed with HTTP ${response.status}`);
}
return payload;
}
function deploymentStatus(payload) {
return String(payload.status || payload.deployment?.status || payload.data?.status || "unknown").toLowerCase();
}
function deploymentUuid(payload) {
const value = payload.deployment_uuid || payload.uuid || payload.deployment?.uuid || payload.data?.deployment_uuid || payload.data?.uuid || "";
return typeof value === "string" ? value : "";
}
async function trigger(resourceUuid) {
if (!resourceUuid) {
fail("Coolify resource UUID is required");
}
const payload = await coolify(`/applications/${encodeURIComponent(resourceUuid)}/start?force=true&instant_deploy=true`, {
method: "POST",
});
const uuid = deploymentUuid(payload);
if (!uuid) {
fail("Coolify deployment UUID was not returned by the start request");
}
process.stdout.write(uuid);
}
async function waitForDeployment(uuid, label) {
if (!uuid) {
fail("Coolify deployment UUID is required");
}
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const payload = await coolify(`/deployments/${encodeURIComponent(uuid)}`);
const status = deploymentStatus(payload);
process.stdout.write(`${label} deployment status: ${status}\n`);
if (["finished", "success", "succeeded", "completed", "done"].includes(status)) {
return;
}
if (["failed", "error", "cancelled", "canceled"].includes(status)) {
fail(`${label} deployment failed`);
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
fail(`${label} deployment did not finish in time`);
}
if (command === "trigger") {
await trigger(args[0] || "");
} else if (command === "wait") {
await waitForDeployment(args[0] || "", args[1] || "Coolify");
} else {
fail("Usage: coolify-deploy.mjs trigger <resource-uuid> | wait <deployment-uuid> <label>");
}
@@ -0,0 +1,492 @@
#!/usr/bin/env node
import path from "node:path";
import { fileURLToPath } from "node:url";
export const GROUPS = [
{ name: "general", healthPort: "9101", adminPort: "9201", concurrencyKey: "WORKER_CONCURRENCY_GENERAL", concurrency: "10" },
{ name: "mail", healthPort: "9102", adminPort: "9202", concurrencyKey: "WORKER_CONCURRENCY_MAIL", concurrency: "5" },
{ name: "import", healthPort: "9103", adminPort: "9203", concurrencyKey: "WORKER_CONCURRENCY_IMPORT", concurrency: "1" },
{ name: "webhook", healthPort: "9104", adminPort: "9204", concurrencyKey: "WORKER_CONCURRENCY_WEBHOOK", concurrency: "20" },
{ name: "plugin", healthPort: "9105", adminPort: "9205", concurrencyKey: "WORKER_CONCURRENCY_PLUGIN", concurrency: "5" },
];
const [, , environment = "staging"] = process.argv;
const prefix = environment === "production" ? "COOLIFY" : "COOLIFY_STAGING";
const apiUrl = (process.env.SELECTED_COOLIFY_API_URL || process.env[`${prefix}_API_URL`] || "").replace(/\/+$/, "");
const token = process.env.SELECTED_COOLIFY_TOKEN || process.env[`${prefix}_TOKEN`] || "";
const imageTag = process.env.IMAGE_TAG || (environment === "production" ? "main" : "staging");
const registry = process.env.REGISTRY || "";
const imageNamespace = process.env.IMAGE_NAMESPACE || "";
const imagePrefix = process.env.IMAGE_PREFIX || "";
const workerImage = process.env.WORKER_IMAGE || [registry, imageNamespace, imagePrefix, "worker"].filter(Boolean).join("/");
const existingResourceUuids = process.env[`${prefix}_RESOURCE_UUIDS`] || "";
const resolvedEnvName = process.env.COOLIFY_RESOLVED_RESOURCE_UUIDS_ENV_NAME || (environment === "production" ? "PRODUCTION_RESOLVED_RESOURCE_UUIDS" : "STAGING_RESOLVED_RESOURCE_UUIDS");
const provisionEnabled = process.env[`${prefix}_PROVISION_WORKER_GROUPS`] === "true";
const configuredWorkerEnvSourceUuid = process.env[`${prefix}_WORKER_ENV_SOURCE_UUID`] || process.env.WORKER_ENV_SOURCE_UUID || "";
export const WORKER_ENV_OVERRIDE_KEYS = new Set([
"WORKER_ENABLED",
"WORKER_QUEUE_GROUP",
"WORKER_CONCURRENCY",
"WORKER_HEALTH_PORT",
"WORKER_ADMIN_PORT",
"WORKER_CONCURRENCY_GENERAL",
"WORKER_CONCURRENCY_MAIL",
"WORKER_CONCURRENCY_IMPORT",
"WORKER_CONCURRENCY_WEBHOOK",
"WORKER_CONCURRENCY_PLUGIN",
"MARKETING_SEQUENCES_ENABLED",
"TRANSACTIONAL_MAIL_OUTBOX_REQUIRED",
"OUTBOX_DISPATCHER_ENABLED",
"OUTBOX_DISPATCHER_REQUIRED",
"PLUGIN_HOOK_FANOUT_CONCURRENCY",
"PLUGIN_HOOK_FANOUT_ENABLED",
"PLUGIN_HOOK_FANOUT_REQUIRED",
"PLUGIN_RUNTIME_PRODUCTION_ENABLED",
"WORKFLOW_TRIGGER_EXECUTOR_MODE",
"WORKFLOW_TRIGGER_OUTBOX_ENABLED",
"WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY",
"WORKFLOW_TRIGGER_OUTBOX_REQUIRED",
]);
function fail(message) {
process.stderr.write(`${message}\n`);
process.exit(1);
}
function requireEnv(name) {
const value = process.env[name] || "";
if (!value) {
fail(`${name} is required to provision missing Coolify worker applications`);
}
return value;
}
async function coolify(path, options = {}) {
if (!apiUrl || !token) {
fail("Coolify API URL and token are required");
}
const response = await fetch(`${apiUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
...(options.body ? { "Content-Type": "application/json" } : {}),
...options.headers,
},
});
const text = await response.text();
let payload = {};
if (text.trim()) {
try {
payload = JSON.parse(text);
} catch {
payload = { message: text.trim() };
}
}
if (!response.ok) {
fail(`Coolify API request failed with HTTP ${response.status}`);
}
return payload;
}
function asArray(payload) {
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload?.data)) return payload.data;
return [];
}
function appText(app) {
return [
app.name,
app.description,
app.docker_registry_image_name,
app.fqdn,
app.domain,
app.domains,
JSON.stringify(app.environment_variables || app.envs || app.environment || ""),
]
.filter(Boolean)
.join(" ");
}
function matchesWorkerGroup(app, group) {
if (app.docker_registry_image_name !== workerImage) return false;
const text = appText(app);
const envPattern = new RegExp(`WORKER_QUEUE_GROUP\\s*(?:=|:)\\s*["']?${group}\\b`, "i");
const namePattern = new RegExp(`(?:^|[\\s/_-])worker[\\s_-]*${group}(?:$|[\\s:_-])`, "i");
return envPattern.test(text) || namePattern.test(text);
}
function isGroupWorker(app) {
return GROUPS.some((group) => matchesWorkerGroup(app, group.name));
}
function resolveWorkerEnvSourceUuid(apps, allowed) {
if (configuredWorkerEnvSourceUuid) {
const app = apps.find((candidate) => candidate.uuid === configuredWorkerEnvSourceUuid);
if (!app) {
fail(`${prefix}_WORKER_ENV_SOURCE_UUID does not match an existing Coolify application`);
}
return configuredWorkerEnvSourceUuid;
}
const candidates = apps
.filter((candidate) => candidate.docker_registry_image_name === workerImage)
.filter((candidate) => allowed.size === 0 || allowed.has(candidate.uuid))
.filter((candidate) => !isGroupWorker(candidate));
candidates.sort((a, b) => {
const aName = String(a.name || "");
const bName = String(b.name || "");
if (aName === "boilerplate-worker") return -1;
if (bName === "boilerplate-worker") return 1;
if (/worker/i.test(aName) && !/worker/i.test(bName)) return -1;
if (/worker/i.test(bName) && !/worker/i.test(aName)) return 1;
return aName.localeCompare(bName);
});
return candidates[0]?.uuid || "";
}
function envKey(entry) {
return typeof entry?.key === "string" ? entry.key : "";
}
function envValue(entry) {
let value = typeof entry?.value === "string" ? entry.value : "";
while (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) {
value = value.slice(1, -1);
}
return value;
}
export function desiredWorkerEnv(group) {
const env = {
WORKER_ENABLED: "true",
WORKER_QUEUE_GROUP: group.name,
WORKER_HEALTH_PORT: group.healthPort,
WORKER_ADMIN_PORT: group.adminPort,
[group.concurrencyKey]: group.concurrency,
};
if (group.name === "general") {
Object.assign(env, {
OUTBOX_DISPATCHER_ENABLED: "true",
OUTBOX_DISPATCHER_REQUIRED: "true",
WORKFLOW_TRIGGER_EXECUTOR_MODE: "http",
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY: "5",
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
});
}
if (group.name === "mail") {
Object.assign(env, {
MARKETING_SEQUENCES_ENABLED: "false",
TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true",
});
}
return env;
}
export function envEntriesToRecord(entries) {
const record = {};
for (const entry of entries || []) {
const key = envKey(entry);
if (!key) continue;
record[key] = envValue(entry);
}
return record;
}
export function normalizeWorkflowTriggerExecutorMode(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
return normalized === "http" || normalized === "hybrid" || normalized === "native" ? normalized : "";
}
export function getWorkerEnvDrift(app, group, entries) {
const actual = envEntriesToRecord(entries);
const exactDrift = Object.entries(desiredWorkerEnv(group))
.filter(([key, expected]) => {
if (key === "WORKFLOW_TRIGGER_EXECUTOR_MODE") {
return !normalizeWorkflowTriggerExecutorMode(actual[key]);
}
return actual[key] !== expected;
})
.map(([key, expected]) => ({
key,
expected: key === "WORKFLOW_TRIGGER_EXECUTOR_MODE" ? "http|hybrid|native" : expected,
actual: actual[key] ?? "<missing>",
appUuid: app?.uuid ?? "",
appName: app?.name ?? `worker-${group.name}`,
group: group.name,
}));
const sharedRuntimeDrift = getSharedWorkerRuntimeDrift(app, group, actual);
const groupRuntimeDrift = group.name === "general" ? getGeneralWorkerRuntimeDrift(app, group, actual) : [];
return [...exactDrift, ...sharedRuntimeDrift, ...groupRuntimeDrift];
}
export function getWorkerAppConfigDrift(app, group) {
const appName = app?.name ?? `worker-${group.name}`;
const appUuid = app?.uuid ?? "";
const item = (key, expected, actual) => ({
key,
expected,
actual,
appUuid,
appName,
group: group.name,
});
const expected = {
ports_exposes: group.healthPort,
health_check_enabled: "true",
health_check_path: "/readyz",
health_check_port: group.healthPort,
health_check_host: "127.0.0.1",
};
const actual = {
ports_exposes: normalizeCoolifyConfigValue(app?.ports_exposes),
health_check_enabled: normalizeCoolifyBoolean(app?.health_check_enabled),
health_check_path: normalizeCoolifyConfigValue(app?.health_check_path),
health_check_port: normalizeCoolifyConfigValue(app?.health_check_port),
health_check_host: normalizeCoolifyConfigValue(app?.health_check_host),
};
return Object.entries(expected)
.filter(([key, expectedValue]) => actual[key] !== expectedValue)
.map(([key, expectedValue]) => item(key, expectedValue, actual[key] || "<missing>"));
}
function normalizeCoolifyBoolean(value) {
return value === true || value === "true" || value === 1 || value === "1" ? "true" : value === false || value === "false" || value === 0 || value === "0" ? "false" : "";
}
function normalizeCoolifyConfigValue(value) {
if (value === null || value === undefined) return "";
return String(value).trim();
}
function hasEnvValue(actual, key) {
return typeof actual[key] === "string" && actual[key].trim().length > 0;
}
function getSharedWorkerRuntimeDrift(app, group, actual) {
const drift = [];
const item = (key, expected, actualValue) => ({
key,
expected,
actual: actualValue,
appUuid: app?.uuid ?? "",
appName: app?.name ?? `worker-${group.name}`,
group: group.name,
});
if (!hasEnvValue(actual, "DATABASE_URL")) {
drift.push(item("DATABASE_URL", "configured DATABASE_URL", "<missing>"));
}
if (!hasEnvValue(actual, "BULLMQ_REDIS_URL")) {
drift.push(item("BULLMQ_REDIS_URL", "configured BULLMQ_REDIS_URL", actual.REDIS_URL ? "<missing; REDIS_URL fallback not accepted for managed workers>" : "<missing>"));
}
return drift;
}
function getGeneralWorkerRuntimeDrift(app, group, actual) {
const drift = [];
const item = (key, expected, actualValue) => ({
key,
expected,
actual: actualValue,
appUuid: app?.uuid ?? "",
appName: app?.name ?? `worker-${group.name}`,
group: group.name,
});
const mode = normalizeWorkflowTriggerExecutorMode(actual.WORKFLOW_TRIGGER_EXECUTOR_MODE);
if (mode === "http" || mode === "hybrid") {
if (!hasEnvValue(actual, "WORKFLOW_TRIGGER_EXECUTOR_URL") && !hasEnvValue(actual, "APP_URL") && !hasEnvValue(actual, "WEB_ORIGIN")) {
drift.push(item("WORKFLOW_TRIGGER_EXECUTOR_URL", "configured WORKFLOW_TRIGGER_EXECUTOR_URL, APP_URL, or WEB_ORIGIN", "<missing>"));
}
if (!hasEnvValue(actual, "API_ACCESS_TOKEN")) {
drift.push(item("API_ACCESS_TOKEN", "configured API_ACCESS_TOKEN matching the web runtime", "<missing>"));
} else if (hasEnvValue(actual, "WORKFLOW_TRIGGER_EXECUTOR_TOKEN")) {
drift.push(item("WORKFLOW_TRIGGER_EXECUTOR_TOKEN", "unset; API_ACCESS_TOKEN is the supported workflow bridge credential", "<configured>"));
}
}
if (actual.PLUGIN_RUNTIME_PRODUCTION_ENABLED === "true") {
if (actual.PLUGIN_HOOK_FANOUT_ENABLED === "false") {
drift.push(item("PLUGIN_HOOK_FANOUT_ENABLED", "unset or true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true", "false"));
}
if (actual.PLUGIN_HOOK_FANOUT_REQUIRED !== "true") {
drift.push(item("PLUGIN_HOOK_FANOUT_REQUIRED", "true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true", actual.PLUGIN_HOOK_FANOUT_REQUIRED ?? "<missing>"));
}
}
return drift;
}
async function cloneWorkerEnv(sourceUuid, targetUuid) {
if (!sourceUuid) return;
const [sourceEnvs, targetEnvs] = await Promise.all([
coolify(`/applications/${encodeURIComponent(sourceUuid)}/envs`).then(asArray),
coolify(`/applications/${encodeURIComponent(targetUuid)}/envs`).then(asArray),
]);
const targetKeys = new Set(targetEnvs.map(envKey).filter(Boolean));
for (const entry of sourceEnvs) {
const key = envKey(entry);
if (!key || targetKeys.has(key) || WORKER_ENV_OVERRIDE_KEYS.has(key)) continue;
await coolify(`/applications/${encodeURIComponent(targetUuid)}/envs`, {
method: "POST",
body: JSON.stringify({
key,
value: envValue(entry),
is_literal: entry.is_literal ?? true,
is_preview: entry.is_preview ?? false,
is_multiline: entry.is_multiline ?? false,
is_shown_once: entry.is_shown_once ?? false,
}),
});
targetKeys.add(key);
}
}
async function createWorkerApplication(group, workerEnvSourceUuid) {
if (!provisionEnabled) {
fail(`Missing Coolify worker-${group.name} application. Set ${prefix}_PROVISION_WORKER_GROUPS=true with project/server/destination variables to create it automatically.`);
}
const projectUuid = requireEnv(`${prefix}_PROJECT_UUID`);
const serverUuid = requireEnv(`${prefix}_SERVER_UUID`);
const destinationUuid = requireEnv(`${prefix}_DESTINATION_UUID`);
const environmentUuid = process.env[`${prefix}_ENVIRONMENT_UUID`] || "";
const environmentName = process.env[`${prefix}_ENVIRONMENT_NAME`] || environment;
const body = buildWorkerApplicationBody({
group,
projectUuid,
serverUuid,
destinationUuid,
environmentName,
environmentUuid,
workerImage,
imageTag,
});
const created = await coolify("/applications/dockerimage", {
method: "POST",
body: JSON.stringify(body),
});
const uuid = typeof created.uuid === "string" ? created.uuid : "";
if (!uuid) {
fail(`Coolify did not return a UUID for worker-${group.name}`);
}
await cloneWorkerEnv(workerEnvSourceUuid, uuid);
await ensureWorkerEnv(uuid, group);
process.stdout.write(`Created Coolify worker-${group.name} application\n`);
return uuid;
}
export function buildWorkerApplicationBody({ group, projectUuid, serverUuid, destinationUuid, environmentName, environmentUuid = "", workerImage, imageTag }) {
const body = {
project_uuid: projectUuid,
server_uuid: serverUuid,
environment_name: environmentName,
docker_registry_image_name: workerImage,
docker_registry_image_tag: imageTag,
destination_uuid: destinationUuid,
name: `worker-${group.name}`,
description: `Managed by deploy workflow. WORKER_QUEUE_GROUP=${group.name}`,
ports_exposes: group.healthPort,
health_check_enabled: true,
health_check_path: "/readyz",
health_check_port: group.healthPort,
health_check_host: "127.0.0.1",
health_check_method: "GET",
health_check_return_code: 200,
health_check_scheme: "http",
health_check_interval: 30,
health_check_timeout: 5,
health_check_retries: 3,
health_check_start_period: 20,
instant_deploy: false,
connect_to_docker_network: true,
};
if (environmentUuid) {
body.environment_uuid = environmentUuid;
}
return body;
}
async function ensureWorkerEnv(uuid, group) {
for (const [key, value] of Object.entries(desiredWorkerEnv(group))) {
await coolify(`/applications/${encodeURIComponent(uuid)}/envs`, {
method: "POST",
body: JSON.stringify({
key,
value,
is_literal: true,
is_preview: false,
is_multiline: false,
is_shown_once: false,
}),
});
}
}
async function assertWorkerEnvMatches(uuid, app, group) {
const envs = asArray(await coolify(`/applications/${encodeURIComponent(uuid)}/envs`));
const drift = [...getWorkerAppConfigDrift(app, group), ...getWorkerEnvDrift(app, group, envs)];
if (drift.length > 0) {
const summary = drift.map((item) => `${item.key}: expected ${item.expected}, got ${item.actual}`).join("; ");
fail(`Coolify worker-${group.name} drift detected for ${app?.name || uuid}: ${summary}`);
}
}
async function main() {
if (!["staging", "production"].includes(environment)) {
fail("Usage: coolify-ensure-worker-groups.mjs staging|production");
}
if (!workerImage || workerImage.split("/").length < 4) {
fail("Worker image path could not be resolved from REGISTRY, IMAGE_NAMESPACE, IMAGE_PREFIX, and worker target");
}
const allowed = new Set(existingResourceUuids.split(",").filter(Boolean));
const apps = await coolify("/applications");
const workerEnvSourceUuid = resolveWorkerEnvSourceUuid(apps, allowed);
const resolvedWorkers = [];
for (const group of GROUPS) {
let app = apps.find((candidate) => matchesWorkerGroup(candidate, group.name) && (allowed.size === 0 || allowed.has(candidate.uuid)));
if (!app && provisionEnabled) {
const uuid = await createWorkerApplication(group, workerEnvSourceUuid);
// Re-fetch the freshly created application so config drift checks
// (ports_exposes/health_check_*) validate the persisted resource, not a
// bare { uuid } stub that always reports those fields as <missing>.
const created = await coolify(`/applications/${encodeURIComponent(uuid)}`);
app = created && created.uuid ? created : { uuid };
}
if (!app?.uuid) {
fail(`Missing Coolify worker-${group.name} application`);
}
await assertWorkerEnvMatches(app.uuid, app, group);
resolvedWorkers.push(app.uuid);
}
const resolved = [...allowed, ...resolvedWorkers].filter(Boolean).join(",");
if (process.env.GITHUB_ENV) {
const fs = await import("node:fs");
fs.appendFileSync(process.env.GITHUB_ENV, `${resolvedEnvName}=${resolved}\n`);
}
process.stdout.write(`Coolify ${environment} worker group applications are present\n`);
}
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : "";
if (invokedPath && fileURLToPath(import.meta.url) === invokedPath) {
await main();
}
@@ -0,0 +1,460 @@
import { describe, expect, it } from "vitest";
import {
GROUPS,
WORKER_ENV_OVERRIDE_KEYS,
buildWorkerApplicationBody,
desiredWorkerEnv,
envEntriesToRecord,
getWorkerAppConfigDrift,
getWorkerEnvDrift,
} from "./coolify-ensure-worker-groups.mjs";
function group(name) {
const found = GROUPS.find((item) => item.name === name);
if (!found) {
throw new Error(`Missing test group ${name}`);
}
return found;
}
function entriesFromRecord(record) {
return Object.entries(record).map(([key, value]) => ({ key, value }));
}
function desiredWorkerEnvWithRuntime(workerGroup, overrides = {}) {
return {
...desiredWorkerEnv(workerGroup),
DATABASE_URL: "postgres://redacted",
BULLMQ_REDIS_URL: "redis://redacted",
...overrides,
};
}
describe("coolify worker group env drift", () => {
it("provisions worker apps with readiness health checks on the group health port", () => {
expect(
buildWorkerApplicationBody({
group: group("mail"),
projectUuid: "project_1",
serverUuid: "server_1",
destinationUuid: "destination_1",
environmentName: "production",
environmentUuid: "environment_1",
workerImage: "registry.example.test/toolctrl/worker",
imageTag: "sha-abc123",
})
).toMatchObject({
project_uuid: "project_1",
server_uuid: "server_1",
destination_uuid: "destination_1",
environment_name: "production",
environment_uuid: "environment_1",
docker_registry_image_name: "registry.example.test/toolctrl/worker",
docker_registry_image_tag: "sha-abc123",
name: "worker-mail",
ports_exposes: "9102",
health_check_enabled: true,
health_check_path: "/readyz",
health_check_port: "9102",
health_check_host: "127.0.0.1",
connect_to_docker_network: true,
});
});
it("builds the expected mail worker environment", () => {
expect(desiredWorkerEnv(group("mail"))).toEqual({
WORKER_ENABLED: "true",
WORKER_QUEUE_GROUP: "mail",
WORKER_HEALTH_PORT: "9102",
WORKER_ADMIN_PORT: "9202",
WORKER_CONCURRENCY_MAIL: "5",
MARKETING_SEQUENCES_ENABLED: "false",
TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true",
});
});
it("builds the expected general worker production outbox environment", () => {
expect(desiredWorkerEnv(group("general"))).toMatchObject({
WORKER_ENABLED: "true",
WORKER_QUEUE_GROUP: "general",
WORKER_HEALTH_PORT: "9101",
WORKER_ADMIN_PORT: "9201",
WORKER_CONCURRENCY_GENERAL: "10",
OUTBOX_DISPATCHER_ENABLED: "true",
OUTBOX_DISPATCHER_REQUIRED: "true",
WORKFLOW_TRIGGER_EXECUTOR_MODE: "http",
WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true",
WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY: "5",
WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true",
});
});
it("protects every managed worker env key from baseline-env cloning", () => {
for (const item of GROUPS) {
for (const key of Object.keys(desiredWorkerEnv(item))) {
expect(WORKER_ENV_OVERRIDE_KEYS.has(key)).toBe(true);
}
}
});
it("normalizes quoted Coolify env values", () => {
expect(
envEntriesToRecord([
{ key: "WORKER_ENABLED", value: '"true"' },
{ key: "WORKER_QUEUE_GROUP", value: "'mail'" },
])
).toEqual({
WORKER_ENABLED: "true",
WORKER_QUEUE_GROUP: "mail",
});
});
it("returns no drift when an existing worker app matches the desired env", () => {
const mail = group("mail");
const drift = getWorkerEnvDrift({ uuid: "worker-mail", name: "worker-mail" }, mail, entriesFromRecord(desiredWorkerEnvWithRuntime(mail)));
expect(drift).toEqual([]);
});
it("returns no app config drift when an existing worker app uses readyz on the group health port", () => {
const mail = group("mail");
expect(
getWorkerAppConfigDrift(
{
uuid: "worker-mail",
name: "worker-mail",
ports_exposes: "9102",
health_check_enabled: true,
health_check_path: "/readyz",
health_check_port: 9102,
health_check_host: "127.0.0.1",
},
mail
)
).toEqual([]);
});
it("reports existing worker app health-check drift", () => {
const mail = group("mail");
expect(
getWorkerAppConfigDrift(
{
uuid: "worker-mail",
name: "worker-mail",
ports_exposes: "9101",
health_check_enabled: false,
health_check_path: "/healthz",
health_check_port: "9101",
health_check_host: "0.0.0.0",
},
mail
)
).toEqual([
expect.objectContaining({ key: "ports_exposes", expected: "9102", actual: "9101", group: "mail" }),
expect.objectContaining({ key: "health_check_enabled", expected: "true", actual: "false", group: "mail" }),
expect.objectContaining({ key: "health_check_path", expected: "/readyz", actual: "/healthz", group: "mail" }),
expect.objectContaining({ key: "health_check_port", expected: "9102", actual: "9101", group: "mail" }),
expect.objectContaining({ key: "health_check_host", expected: "127.0.0.1", actual: "0.0.0.0", group: "mail" }),
]);
});
it("accepts a fully configured general worker HTTP bridge", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("accepts WEB_ORIGIN as the general worker HTTP bridge fallback", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WEB_ORIGIN: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("does not require plugin hook fanout before the production plugin runtime gate flips", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "false",
PLUGIN_HOOK_FANOUT_ENABLED: "false",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("requires plugin hook fanout fail-closed when production plugin runtime is enabled", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
PLUGIN_HOOK_FANOUT_ENABLED: "false",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({
key: "PLUGIN_HOOK_FANOUT_ENABLED",
expected: "unset or true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true",
actual: "false",
group: "general",
}),
expect.objectContaining({
key: "PLUGIN_HOOK_FANOUT_REQUIRED",
expected: "true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true",
actual: "<missing>",
group: "general",
}),
]);
});
it("accepts production plugin runtime when general worker hook fanout is required", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
PLUGIN_RUNTIME_PRODUCTION_ENABLED: "true",
PLUGIN_HOOK_FANOUT_REQUIRED: "true",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("accepts native workflow execution as an explicit general worker opt-in", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_MODE: "native",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("normalizes native workflow execution mode like the worker runtime", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_MODE: " NATIVE ",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("accepts hybrid workflow execution when the HTTP fallback is configured", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_MODE: "hybrid",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([]);
});
it("normalizes hybrid workflow execution mode before checking HTTP fallback dependencies", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_MODE: " HYBRID ",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "WORKFLOW_TRIGGER_EXECUTOR_URL", expected: "configured WORKFLOW_TRIGGER_EXECUTOR_URL, APP_URL, or WEB_ORIGIN", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "API_ACCESS_TOKEN", expected: "configured API_ACCESS_TOKEN matching the web runtime", actual: "<missing>", group: "general" }),
]);
});
it("requires the HTTP fallback dependencies for hybrid workflow execution", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_MODE: "hybrid",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "WORKFLOW_TRIGGER_EXECUTOR_URL", expected: "configured WORKFLOW_TRIGGER_EXECUTOR_URL, APP_URL, or WEB_ORIGIN", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "API_ACCESS_TOKEN", expected: "configured API_ACCESS_TOKEN matching the web runtime", actual: "<missing>", group: "general" }),
]);
});
it("rejects legacy-only workflow executor token configuration", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
WORKFLOW_TRIGGER_EXECUTOR_TOKEN: "legacy-token",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "API_ACCESS_TOKEN", expected: "configured API_ACCESS_TOKEN matching the web runtime", actual: "<missing>", group: "general" }),
]);
});
it("rejects legacy workflow executor token even when API_ACCESS_TOKEN is configured", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
WORKFLOW_TRIGGER_EXECUTOR_TOKEN: "legacy-token",
};
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({
key: "WORKFLOW_TRIGGER_EXECUTOR_TOKEN",
expected: "unset; API_ACCESS_TOKEN is the supported workflow bridge credential",
actual: "<configured>",
group: "general",
}),
]);
});
it("reports disabled existing worker apps", () => {
const mail = group("mail");
const entries = { ...desiredWorkerEnvWithRuntime(mail), WORKER_ENABLED: "false" };
expect(getWorkerEnvDrift({ uuid: "worker-mail" }, mail, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "WORKER_ENABLED", expected: "true", actual: "false", group: "mail" }),
]);
});
it("reports wrong queue groups", () => {
const mail = group("mail");
const entries = { ...desiredWorkerEnvWithRuntime(mail), WORKER_QUEUE_GROUP: "general" };
expect(getWorkerEnvDrift({ uuid: "worker-mail" }, mail, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "WORKER_QUEUE_GROUP", expected: "mail", actual: "general" }),
]);
});
it("reports mail workers without the marketing sequence killswitch", () => {
const mail = group("mail");
const entries = { ...desiredWorkerEnvWithRuntime(mail) };
delete entries.MARKETING_SEQUENCES_ENABLED;
expect(getWorkerEnvDrift({ uuid: "worker-mail" }, mail, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "MARKETING_SEQUENCES_ENABLED", expected: "false", actual: "<missing>", group: "mail" }),
]);
});
it("reports mail workers without fail-loud transactional outbox bootstrap", () => {
const mail = group("mail");
const entries = { ...desiredWorkerEnvWithRuntime(mail) };
delete entries.TRANSACTIONAL_MAIL_OUTBOX_REQUIRED;
expect(getWorkerEnvDrift({ uuid: "worker-mail" }, mail, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "TRANSACTIONAL_MAIL_OUTBOX_REQUIRED", expected: "true", actual: "<missing>", group: "mail" }),
]);
});
it("requires DATABASE_URL and BULLMQ_REDIS_URL on every managed worker group", () => {
for (const workerGroup of GROUPS) {
const missingDatabase = desiredWorkerEnvWithRuntime(workerGroup);
delete missingDatabase.DATABASE_URL;
const missingRedis = desiredWorkerEnvWithRuntime(workerGroup);
delete missingRedis.BULLMQ_REDIS_URL;
missingRedis.REDIS_URL = "redis://fallback-not-accepted-for-coolify-workers";
expect(getWorkerEnvDrift({ uuid: `worker-${workerGroup.name}` }, workerGroup, entriesFromRecord(missingDatabase))).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: "DATABASE_URL", expected: "configured DATABASE_URL", actual: "<missing>", group: workerGroup.name }),
])
);
expect(getWorkerEnvDrift({ uuid: `worker-${workerGroup.name}` }, workerGroup, entriesFromRecord(missingRedis))).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: "BULLMQ_REDIS_URL",
expected: "configured BULLMQ_REDIS_URL",
actual: "<missing; REDIS_URL fallback not accepted for managed workers>",
group: workerGroup.name,
}),
])
);
}
});
it("reports missing required general-worker outbox activation", () => {
const general = group("general");
const entries = {
...desiredWorkerEnvWithRuntime(general),
BULLMQ_REDIS_URL: "redis://redacted",
WORKFLOW_TRIGGER_EXECUTOR_URL: "https://app.example.test",
API_ACCESS_TOKEN: "redacted-token",
OUTBOX_DISPATCHER_REQUIRED: "false",
};
delete entries.WORKFLOW_TRIGGER_EXECUTOR_MODE;
entries.WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY = "1";
delete entries.WORKFLOW_TRIGGER_OUTBOX_REQUIRED;
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "OUTBOX_DISPATCHER_REQUIRED", expected: "true", actual: "false", group: "general" }),
expect.objectContaining({ key: "WORKFLOW_TRIGGER_EXECUTOR_MODE", expected: "http|hybrid|native", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY", expected: "5", actual: "1", group: "general" }),
expect.objectContaining({ key: "WORKFLOW_TRIGGER_OUTBOX_REQUIRED", expected: "true", actual: "<missing>", group: "general" }),
]);
});
it("reports missing general worker Redis and HTTP bridge dependencies", () => {
const general = group("general");
const entries = { ...desiredWorkerEnv(general) };
expect(getWorkerEnvDrift({ uuid: "worker-general" }, general, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "DATABASE_URL", expected: "configured DATABASE_URL", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "BULLMQ_REDIS_URL", expected: "configured BULLMQ_REDIS_URL", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "WORKFLOW_TRIGGER_EXECUTOR_URL", expected: "configured WORKFLOW_TRIGGER_EXECUTOR_URL, APP_URL, or WEB_ORIGIN", actual: "<missing>", group: "general" }),
expect.objectContaining({ key: "API_ACCESS_TOKEN", expected: "configured API_ACCESS_TOKEN matching the web runtime", actual: "<missing>", group: "general" }),
]);
});
it("reports swapped or missing worker ports", () => {
const plugin = group("plugin");
const entries = { ...desiredWorkerEnvWithRuntime(plugin), WORKER_HEALTH_PORT: "9205" };
delete entries.WORKER_ADMIN_PORT;
expect(getWorkerEnvDrift({ uuid: "worker-plugin" }, plugin, entriesFromRecord(entries))).toEqual([
expect.objectContaining({ key: "WORKER_HEALTH_PORT", expected: "9105", actual: "9205" }),
expect.objectContaining({ key: "WORKER_ADMIN_PORT", expected: "9205", actual: "<missing>" }),
]);
});
it("reports missing or wrong group concurrency", () => {
const webhook = group("webhook");
const wrong = { ...desiredWorkerEnvWithRuntime(webhook), WORKER_CONCURRENCY_WEBHOOK: "5" };
const missing = { ...desiredWorkerEnvWithRuntime(webhook) };
delete missing.WORKER_CONCURRENCY_WEBHOOK;
expect(getWorkerEnvDrift({ uuid: "worker-webhook" }, webhook, entriesFromRecord(wrong))).toEqual([
expect.objectContaining({ key: "WORKER_CONCURRENCY_WEBHOOK", expected: "20", actual: "5" }),
]);
expect(getWorkerEnvDrift({ uuid: "worker-webhook" }, webhook, entriesFromRecord(missing))).toEqual([
expect.objectContaining({ key: "WORKER_CONCURRENCY_WEBHOOK", expected: "20", actual: "<missing>" }),
]);
});
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
: "${COOLIFY_STAGING_SSH_HOST:?COOLIFY_STAGING_SSH_HOST is required}"
: "${COOLIFY_STAGING_SSH_KEY:?COOLIFY_STAGING_SSH_KEY is required}"
: "${REGISTRY:?REGISTRY is required}"
: "${REGISTRY_USER:?REGISTRY_USER is required}"
: "${REGISTRY_PAT:?REGISTRY_PAT is required}"
: "${IMAGE_NAMESPACE:?IMAGE_NAMESPACE is required}"
: "${IMAGE_PREFIX:?IMAGE_PREFIX is required}"
: "${IMAGE_TAG:?IMAGE_TAG is required}"
SSH_USER="${COOLIFY_STAGING_SSH_USER:-root}"
KEY_FILE="$(mktemp)"
KNOWN_HOSTS_FILE="$(mktemp)"
cleanup() {
rm -f "$KEY_FILE" "$KNOWN_HOSTS_FILE"
}
trap cleanup EXIT
printf "%s\n" "$COOLIFY_STAGING_SSH_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
if command -v ssh-keyscan >/dev/null 2>&1; then
ssh-keyscan -H "$COOLIFY_STAGING_SSH_HOST" > "$KNOWN_HOSTS_FILE" 2>/dev/null || true
fi
SSH_STRICT_OPTIONS=(
-o BatchMode=yes
-o ConnectTimeout="${COOLIFY_PREPULL_SSH_CONNECT_TIMEOUT_SECONDS:-10}"
-o ServerAliveInterval=10
-o ServerAliveCountMax=3
-o UserKnownHostsFile="$KNOWN_HOSTS_FILE"
)
if [[ -s "$KNOWN_HOSTS_FILE" ]]; then
SSH_STRICT_OPTIONS+=(-o StrictHostKeyChecking=yes)
else
SSH_STRICT_OPTIONS+=(-o StrictHostKeyChecking=accept-new)
fi
remote() {
ssh -i "$KEY_FILE" "${SSH_STRICT_OPTIONS[@]}" "${SSH_USER}@${COOLIFY_STAGING_SSH_HOST}" "$@"
}
printf "%s\n" "$REGISTRY_PAT" | remote docker login "$REGISTRY" --username "$REGISTRY_USER" --password-stdin >/dev/null
PULL_TIMEOUT_SECONDS="${COOLIFY_PREPULL_PULL_TIMEOUT_SECONDS:-120}"
for TARGET in web api worker; do
IMAGE="$REGISTRY/$IMAGE_NAMESPACE/$IMAGE_PREFIX/$TARGET:$IMAGE_TAG"
remote timeout "$PULL_TIMEOUT_SECONDS" docker pull "$IMAGE" >/dev/null &
done
wait
echo "Coolify host pre-pulled web, api, and worker images for the selected tag"
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// Renders a Vitest coverage-summary.json into either a shields.io badge URL
// (default) or a sticky PR-comment markdown table (--pr-comment).
//
// Usage:
// node scripts/coverage-summary.mjs # prints shields.io URL
// node scripts/coverage-summary.mjs --pr-comment # prints markdown for PR
// node scripts/coverage-summary.mjs --json # prints raw totals
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const MARKER = "<!-- coverage-report -->";
const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
const targetsPath = path.join(repoRoot, "scripts", "coverage-targets.json");
export function resolveSummaryPath({ root = repoRoot, env = process.env, fsApi = fs } = {}) {
if (env.COVERAGE_SUMMARY_PATH) {
return path.resolve(root, env.COVERAGE_SUMMARY_PATH);
}
const candidates = [
path.join(root, "coverage-gate", "coverage-summary.json"),
path.join(root, "coverage", "coverage-summary.json"),
];
return candidates.find((candidate) => fsApi.existsSync(candidate)) ?? candidates[0];
}
function loadSummary() {
const summaryPath = resolveSummaryPath();
if (!fs.existsSync(summaryPath)) return null;
return JSON.parse(fs.readFileSync(summaryPath, "utf8"));
}
function loadTargets() {
if (!fs.existsSync(targetsPath)) return [];
return JSON.parse(fs.readFileSync(targetsPath, "utf8"));
}
function aggregateTotals(summary) {
const total = summary.total ?? null;
if (!total) return null;
return {
statements: total.statements?.pct ?? 0,
branches: total.branches?.pct ?? 0,
functions: total.functions?.pct ?? 0,
lines: total.lines?.pct ?? 0,
};
}
function pickColor(pct) {
if (pct >= 90) return "brightgreen";
if (pct >= 75) return "green";
if (pct >= 60) return "yellowgreen";
if (pct >= 50) return "yellow";
if (pct >= 40) return "orange";
return "red";
}
function shieldsUrl(totals) {
if (!totals) return "https://img.shields.io/badge/coverage-unknown-lightgrey";
const pct = totals.statements.toFixed(1);
return `https://img.shields.io/badge/coverage-${pct}%25-${pickColor(totals.statements)}`;
}
function evaluatePerPath(summary, targets) {
const rows = [];
for (const target of targets) {
const filtered = Object.entries(summary).filter(([file]) => {
if (file === "total") return false;
const rel = path.relative(repoRoot, file);
return target.pathPrefixes.some((prefix) => rel.startsWith(prefix));
});
if (filtered.length === 0) {
rows.push({ name: target.name, statementsPct: null, branchesPct: null, target, status: "skip" });
continue;
}
const totals = filtered.reduce(
(acc, [, m]) => ({
statements: acc.statements + m.statements.covered,
statementsTotal: acc.statementsTotal + m.statements.total,
branches: acc.branches + m.branches.covered,
branchesTotal: acc.branchesTotal + m.branches.total,
}),
{ statements: 0, statementsTotal: 0, branches: 0, branchesTotal: 0 }
);
const statementsPct = totals.statementsTotal > 0 ? (totals.statements / totals.statementsTotal) * 100 : 0;
const branchesPct = totals.branchesTotal > 0 ? (totals.branches / totals.branchesTotal) * 100 : 0;
const passes = statementsPct >= target.statements && branchesPct >= target.branches;
rows.push({
name: target.name,
statementsPct,
branchesPct,
target,
status: passes ? "pass" : "fail",
});
}
return rows;
}
function renderPrComment(totals, rows) {
const lines = [];
lines.push(MARKER);
lines.push("## Coverage Report");
lines.push("");
if (!totals) {
lines.push("_coverage-summary.json not found — vitest with --coverage did not run._");
return lines.join("\n");
}
lines.push("| metric | total |");
lines.push("|---|---|");
lines.push(`| statements | **${totals.statements.toFixed(1)}%** |`);
lines.push(`| branches | **${totals.branches.toFixed(1)}%** |`);
lines.push(`| functions | **${totals.functions.toFixed(1)}%** |`);
lines.push(`| lines | **${totals.lines.toFixed(1)}%** |`);
lines.push("");
lines.push("### Per-path thresholds");
lines.push("");
lines.push("| path | statements | branches | gate | status |");
lines.push("|---|---|---|---|---|");
for (const row of rows) {
const tag = row.target.criticalPath ? " (critical)" : "";
const statements = row.statementsPct === null ? "n/a" : `${row.statementsPct.toFixed(1)}%`;
const branches = row.branchesPct === null ? "n/a" : `${row.branchesPct.toFixed(1)}%`;
const gate = `${row.target.statements}% / ${row.target.branches}%`;
const icon = row.status === "pass" ? "PASS" : row.status === "fail" ? "FAIL" : "SKIP";
lines.push(`| ${row.name}${tag} | ${statements} | ${branches} | ${gate} | ${icon} |`);
}
lines.push("");
lines.push("_Bump thresholds by editing `scripts/coverage-targets.json`. See `docs/architecture/test-coverage.md`._");
return lines.join("\n");
}
export function main() {
const argv = process.argv.slice(2);
const summary = loadSummary();
const targets = loadTargets();
const totals = summary ? aggregateTotals(summary) : null;
if (argv.includes("--pr-comment")) {
const rows = summary ? evaluatePerPath(summary, targets) : [];
process.stdout.write(renderPrComment(totals, rows) + "\n");
return;
}
if (argv.includes("--json")) {
process.stdout.write(JSON.stringify(totals ?? {}, null, 2) + "\n");
return;
}
// Default: shields.io badge URL
process.stdout.write(shieldsUrl(totals) + "\n");
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
@@ -0,0 +1,413 @@
[
{
"name": "apps/api",
"pathPrefixes": ["apps/api/src/"],
"includeGlobs": ["apps/api/src"],
"statements": 70,
"branches": 60
},
{
"name": "apps/api/src/auth (critical-path: Auth)",
"pathPrefixes": ["apps/api/src/auth/"],
"includeGlobs": ["apps/api/src/auth"],
"statements": 99,
"branches": 92,
"sprintTarget": { "statements": 90, "branches": 85 },
"criticalPath": true
},
{
"name": "apps/api/src/billing (critical-path: Billing)",
"pathPrefixes": ["apps/api/src/billing/"],
"includeGlobs": ["apps/api/src/billing"],
"statements": 99,
"branches": 97,
"sprintTarget": { "statements": 99, "branches": 95 },
"criticalPath": true
},
{
"name": "apps/api/src/isolation (critical-path: Tenant-Isolation)",
"pathPrefixes": ["apps/api/src/isolation/"],
"includeGlobs": ["apps/api/src/isolation"],
"statements": 99,
"branches": 99,
"sprintTarget": { "statements": 95, "branches": 90 },
"criticalPath": true
},
{
"name": "apps/web auth services (critical-path: Auth)",
"pathPrefixes": ["apps/web/app/services/auth/"],
"includeGlobs": ["apps/web/app/services/auth"],
"statements": 97,
"branches": 88,
"sprintTarget": { "statements": 90, "branches": 85 },
"criticalPath": true
},
{
"name": "apps/web auth utils (critical-path: Auth)",
"pathPrefixes": ["apps/web/app/utils/auth/"],
"includeGlobs": ["apps/web/app/utils/auth"],
"statements": 97,
"branches": 92,
"sprintTarget": { "statements": 90, "branches": 85 },
"criticalPath": true
},
{
"name": "apps/web session.server (critical-path: Auth)",
"pathPrefixes": ["apps/web/app/utils/session.server.ts"],
"includeGlobs": [
"apps/web/app/utils/session.server.test.ts",
"apps/web/app/utils/session.csrf-regression.test.ts",
"apps/web/app/utils/session.impersonation.test.ts"
],
"statements": 99,
"branches": 98,
"sprintTarget": { "statements": 95, "branches": 90 },
"criticalPath": true
},
{
"name": "apps/web stripe.server (critical-path: Billing)",
"pathPrefixes": ["apps/web/app/utils/stripe.server.ts"],
"includeGlobs": ["apps/web/app/utils/stripe.server.test.ts"],
"statements": 95,
"branches": 90,
"sprintTarget": { "statements": 85, "branches": 75 },
"criticalPath": true
},
{
"name": "apps/web db/permissions (critical-path: Permissions)",
"pathPrefixes": ["apps/web/app/utils/db/permissions/"],
"includeGlobs": ["apps/web/app/utils/db/permissions"],
"statements": 99,
"branches": 98,
"sprintTarget": { "statements": 90, "branches": 80 },
"criticalPath": true
},
{
"name": "apps/web db/tenants.db.server (critical-path: Tenant-Isolation)",
"pathPrefixes": ["apps/web/app/utils/db/tenants.db.server.ts"],
"includeGlobs": ["apps/web/app/utils/db/tenants.db.server.test.ts"],
"statements": 98,
"branches": 95,
"sprintTarget": { "statements": 95, "branches": 90 },
"criticalPath": true
},
{
"name": "apps/worker",
"pathPrefixes": ["apps/worker/src/"],
"includeGlobs": ["apps/worker/src"],
"statements": 50,
"branches": 40
},
{
"name": "apps/hocuspocus",
"pathPrefixes": ["apps/hocuspocus/src/"],
"includeGlobs": ["apps/hocuspocus/src"],
"statements": 88,
"branches": 90,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "apps/mobile",
"pathPrefixes": ["apps/mobile/src/"],
"includeGlobs": ["apps/mobile/src"],
"statements": 79,
"branches": 79,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/entitlements (critical-path: Plan-Gating)",
"pathPrefixes": ["packages/entitlements/src/"],
"includeGlobs": ["packages/entitlements/src"],
"statements": 96,
"branches": 89,
"sprintTarget": { "statements": 90, "branches": 80 },
"criticalPath": true
},
{
"name": "packages/foundation",
"pathPrefixes": ["packages/foundation/src/"],
"includeGlobs": ["packages/foundation/src"],
"statements": 70,
"branches": 60
},
{
"name": "packages/db (boundary helpers)",
"pathPrefixes": ["packages/db/src/"],
"includeGlobs": ["packages/db/src"],
"statements": 60,
"branches": 50
},
{
"name": "packages/db outbox (critical-path: Outbox)",
"pathPrefixes": ["packages/db/src/outbox"],
"includeGlobs": ["packages/db/src"],
"statements": 92,
"branches": 88,
"sprintTarget": { "statements": 90, "branches": 80 },
"criticalPath": true
},
{
"name": "packages/shared-store (critical-path: SharedStore)",
"pathPrefixes": ["packages/shared-store/src/"],
"includeGlobs": ["packages/shared-store/src"],
"statements": 91,
"branches": 90,
"sprintTarget": { "statements": 90, "branches": 80 },
"criticalPath": true
},
{
"name": "packages/mail",
"pathPrefixes": ["packages/mail/src/"],
"includeGlobs": ["packages/mail/src"],
"statements": 75,
"branches": 65
},
{
"name": "packages/ai",
"pathPrefixes": ["packages/ai/src/"],
"includeGlobs": ["packages/ai/src"],
"statements": 81,
"branches": 81,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/cli",
"pathPrefixes": ["packages/cli/src/"],
"includeGlobs": ["packages/cli/src"],
"statements": 80,
"branches": 83,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/config",
"pathPrefixes": ["packages/config/src/"],
"includeGlobs": ["packages/config/src"],
"statements": 98,
"branches": 95,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/feature-flags",
"pathPrefixes": ["packages/feature-flags/src/"],
"includeGlobs": ["packages/feature-flags/src"],
"statements": 94,
"branches": 86,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/i18n",
"pathPrefixes": ["packages/i18n/src/"],
"includeGlobs": ["packages/i18n/src"],
"statements": 95,
"branches": 75,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/plugin-runtime",
"pathPrefixes": ["packages/plugin-runtime/src/"],
"includeGlobs": ["packages/plugin-runtime/src"],
"statements": 92,
"branches": 88,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/queue",
"pathPrefixes": ["packages/queue/src/"],
"includeGlobs": ["packages/queue/src"],
"statements": 93,
"branches": 95,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/exports",
"pathPrefixes": ["packages/exports/src/"],
"includeGlobs": ["packages/exports/src"],
"statements": 90,
"branches": 83,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/imports",
"pathPrefixes": ["packages/imports/src/"],
"includeGlobs": ["packages/imports/src"],
"statements": 91,
"branches": 87,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/realtime",
"pathPrefixes": ["packages/realtime/src/"],
"includeGlobs": ["packages/realtime/src"],
"statements": 91,
"branches": 85,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/search",
"pathPrefixes": ["packages/search/src/"],
"includeGlobs": ["packages/search/src"],
"statements": 88,
"branches": 71,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/storage",
"pathPrefixes": ["packages/storage/src/"],
"includeGlobs": ["packages/storage/src"],
"statements": 82,
"branches": 84,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/vector-store",
"pathPrefixes": ["packages/vector-store/src/"],
"includeGlobs": ["packages/vector-store/src"],
"statements": 94,
"branches": 90,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/metrics",
"pathPrefixes": ["packages/metrics/src/"],
"includeGlobs": ["packages/metrics/src"],
"statements": 98,
"branches": 90,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/form-widget",
"pathPrefixes": ["packages/form-widget/src/"],
"includeGlobs": ["packages/form-widget/src"],
"statements": 96,
"branches": 86,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/email-sequences",
"pathPrefixes": ["packages/email-sequences/src/"],
"includeGlobs": ["packages/email-sequences/src"],
"statements": 84,
"branches": 77,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/email-templates",
"pathPrefixes": ["packages/email-templates/src/"],
"includeGlobs": ["packages/email-templates/src"],
"statements": 96,
"branches": 86,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/error-reporter",
"pathPrefixes": ["packages/error-reporter/src/"],
"includeGlobs": ["packages/error-reporter/src"],
"statements": 81,
"branches": 86,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/observability",
"pathPrefixes": ["packages/observability/src/"],
"includeGlobs": ["packages/observability/src"],
"statements": 99,
"branches": 99,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/marketing-outbound",
"pathPrefixes": ["packages/marketing-outbound/src/"],
"includeGlobs": ["packages/marketing-outbound/src"],
"statements": 85,
"branches": 65,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/api-client-ts",
"pathPrefixes": ["packages/api-client-ts/src/"],
"includeGlobs": ["packages/api-client-ts/src"],
"statements": 70,
"branches": 80,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/plugins",
"pathPrefixes": ["packages/plugins/src/"],
"includeGlobs": ["packages/plugins/src", "packages/plugins/test"],
"statements": 99,
"branches": 86,
"sprintTarget": { "statements": 98, "branches": 80 }
},
{
"name": "packages/plugin-sdk",
"pathPrefixes": ["packages/plugin-sdk/src/"],
"includeGlobs": ["packages/plugin-sdk/src"],
"statements": 99,
"branches": 99,
"sprintTarget": { "statements": 75, "branches": 65 }
},
{
"name": "packages/ui-app",
"pathPrefixes": ["packages/ui-app/src/"],
"includeGlobs": ["packages/ui-app/src"],
"statements": 94,
"branches": 73,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/ui-mock",
"pathPrefixes": ["packages/ui-mock/src/"],
"includeGlobs": ["packages/ui-mock/src"],
"statements": 97,
"branches": 68,
"sprintTarget": { "statements": 70, "branches": 60 }
},
{
"name": "packages/ui-subframe (visual coverage via Storybook lost-pixel)",
"pathPrefixes": ["packages/ui-subframe/src/"],
"includeGlobs": ["packages/ui-subframe/src"],
"statements": 30,
"branches": 20,
"sprintTarget": { "statements": 50, "branches": 40 }
},
{
"name": "packages/view-models",
"pathPrefixes": ["packages/view-models/src/"],
"includeGlobs": ["packages/view-models/src"],
"statements": 86,
"branches": 71,
"sprintTarget": { "statements": 75, "branches": 65 }
},
{
"name": "packages/url",
"pathPrefixes": ["packages/url/src/"],
"includeGlobs": ["packages/url/src"],
"statements": 98,
"branches": 95,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/logger",
"pathPrefixes": ["packages/logger/src/"],
"includeGlobs": ["packages/logger/src"],
"statements": 99,
"branches": 99,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/brand",
"pathPrefixes": ["packages/brand/src/"],
"includeGlobs": ["packages/brand/src"],
"statements": 99,
"branches": 93,
"sprintTarget": { "statements": 80, "branches": 70 }
},
{
"name": "packages/api-types (type-only — runtime coverage near zero by design)",
"pathPrefixes": ["packages/api-types/src/"],
"includeGlobs": ["packages/api-types/src"],
"statements": 0,
"branches": 0,
"sprintTarget": { "statements": 0, "branches": 0 }
}
]
@@ -0,0 +1,138 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifestRoot = path.join(repoRoot, "docs", "feature-manifests");
const classifications = new Set(["core", "plugin-candidate", "remove-candidate", "mixed"]);
const ownerships = new Set(["tenant-owned", "admin-managed", "hybrid", "system-only"]);
export function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token.startsWith("--")) {
throw new Error(`Unexpected argument: ${token}`);
}
const key = token.slice(2);
if (key === "force") {
args.force = true;
continue;
}
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error(`Missing value for --${key}`);
}
args[key] = value;
index += 1;
}
return args;
}
export function createFeatureManifest({
id,
title,
classification = "plugin-candidate",
ownership = "hybrid",
owner = "boilerplate",
description = "Describe the feature contract before implementation.",
lastReviewed = process.env.TOOLCTRL_MANIFEST_DATE ?? new Date().toISOString().slice(0, 10),
}) {
if (!/^[a-z0-9][a-z0-9-]*$/.test(id ?? "")) {
throw new Error("--id must match ^[a-z0-9][a-z0-9-]*$");
}
if (!title?.trim()) {
throw new Error("--title is required");
}
if (!classifications.has(classification)) {
throw new Error(`--classification must be one of ${Array.from(classifications).join(", ")}`);
}
if (!ownerships.has(ownership)) {
throw new Error(`--ownership must be one of ${Array.from(ownerships).join(", ")}`);
}
const pruningAction = classification === "core" ? "keep" : classification === "remove-candidate" ? "remove" : classification === "plugin-candidate" ? "extract-plugin" : "review";
return {
schemaVersion: "1.0",
id,
title,
classification,
status: "proposed",
owner,
description,
rationale: ["Document why this belongs in the boilerplate, a plugin, or the removal backlog before code changes land."],
codeAreas: ["Add concrete source paths before implementation, for example apps/web/app/routes/... or packages/..."],
dependencies: [],
consumers: [],
integration: {
ownership,
surfaces: {
adminRoutes: [],
tenantRoutes: [],
publicRoutes: [],
apiRoutes: [],
},
configuration: {
admin: [],
tenant: [],
environment: [],
secrets: [],
},
workers: {
required: false,
queues: [],
jobs: [],
notes: [],
},
externalProviders: [],
permissions: ["app.feature.view"],
auditEvents: ["feature.viewed"],
billingImpacts: ["None declared yet; confirm plan gates, metering, and provider costs before implementation."],
uat: ["Verify positive access, denied access, tenant isolation, i18n output, and host-aware routing for this feature."],
},
boundaries: {
mustKeep: [],
mayMoveToPlugin: [],
mustNotTouch: [],
},
pruning: {
phase: "planning",
action: pruningAction,
notes: ["Generated scaffold. Replace placeholders before treating this manifest as implementation-ready."],
},
validation: {
source: [`docs/feature-manifests/${id}.json`],
lastReviewed,
},
};
}
export function writeFeatureManifest(args, root = repoRoot) {
const manifest = createFeatureManifest(args);
const target = path.join(root, "docs", "feature-manifests", `${manifest.id}.json`);
if (fs.existsSync(target) && !args.force) {
throw new Error(`${path.relative(root, target)} already exists. Pass --force to overwrite.`);
}
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, `${JSON.stringify(manifest, null, 2)}\n`);
return { manifest, target };
}
function printUsage() {
console.error("Usage: node scripts/create-feature-manifest.mjs --id my-feature --title \"My Feature\" [--classification plugin-candidate] [--ownership hybrid] [--owner boilerplate] [--description text] [--force]");
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
try {
const result = writeFeatureManifest(parseArgs(process.argv.slice(2)));
console.log(`Created ${path.relative(repoRoot, result.target)}`);
} catch (error) {
printUsage();
console.error(`FAIL ${error.message}`);
process.exit(1);
}
}
@@ -0,0 +1,69 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { afterEach, describe, it } from "vitest";
import { createFeatureManifest, parseArgs, writeFeatureManifest } from "./create-feature-manifest.mjs";
const fixtures = [];
function createRoot() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "feature-manifest-"));
fixtures.push(root);
return root;
}
afterEach(() => {
while (fixtures.length > 0) {
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
}
});
describe("create-feature-manifest", () => {
it("creates an integration-aware manifest scaffold", () => {
const manifest = createFeatureManifest({
id: "customer-health",
title: "Customer Health",
classification: "plugin-candidate",
ownership: "admin-managed",
lastReviewed: "2026-05-15",
});
assert.equal(manifest.schemaVersion, "1.0");
assert.equal(manifest.id, "customer-health");
assert.equal(manifest.pruning.action, "extract-plugin");
assert.equal(manifest.integration.ownership, "admin-managed");
assert.deepEqual(Object.keys(manifest.integration.surfaces), ["adminRoutes", "tenantRoutes", "publicRoutes", "apiRoutes"]);
assert.match(manifest.integration.permissions[0], /^app\./);
assert.match(manifest.integration.auditEvents[0], /^feature\./);
assert.equal(manifest.validation.lastReviewed, "2026-05-15");
});
it("writes without overwriting existing manifests unless forced", () => {
const root = createRoot();
const args = {
id: "agent-demo",
title: "Agent Demo",
classification: "core",
ownership: "system-only",
lastReviewed: "2026-05-15",
};
const first = writeFeatureManifest(args, root);
assert.equal(path.relative(root, first.target), "docs/feature-manifests/agent-demo.json");
assert.equal(JSON.parse(fs.readFileSync(first.target, "utf8")).pruning.action, "keep");
assert.throws(() => writeFeatureManifest(args, root), /already exists/);
assert.doesNotThrow(() => writeFeatureManifest({ ...args, force: true }, root));
});
it("parses CLI flags and rejects invalid values", () => {
assert.deepEqual(parseArgs(["--id", "my-feature", "--title", "My Feature", "--force"]), {
id: "my-feature",
title: "My Feature",
force: true,
});
assert.throws(() => createFeatureManifest({ id: "Bad_ID", title: "Bad" }), /--id must match/);
assert.throws(() => createFeatureManifest({ id: "ok", title: "" }), /--title is required/);
assert.throws(() => createFeatureManifest({ id: "ok", title: "OK", classification: "legacy" }), /--classification must be/);
});
});
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# check-direct-url.sh — sanity-check the Prisma direct-connect URL before
# running a migration. Prisma Migrate must talk to Postgres directly (not
# through PgBouncer in transaction pool mode), so we want a hard fail
# upfront if DIRECT_URL is missing or unreachable.
#
# Usage:
# scripts/db/check-direct-url.sh # validates DIRECT_URL
# scripts/db/check-direct-url.sh --skip-ping # env-presence only (CI)
#
# Exits non-zero on any failure so callers can short-circuit.
set -euo pipefail
skip_ping=0
if [[ "${1:-}" == "--skip-ping" ]]; then
skip_ping=1
fi
if [[ -z "${DIRECT_URL:-}" ]]; then
if [[ -n "${DATABASE_URL:-}" ]]; then
echo "warning: DIRECT_URL is unset; falling back to DATABASE_URL." >&2
echo " This is only safe when DATABASE_URL bypasses PgBouncer (port 5432)." >&2
echo " See docs/ops/pgbouncer.md." >&2
exit 0
fi
echo "error: DIRECT_URL (or DATABASE_URL) must be set before running prisma migrate." >&2
exit 1
fi
case "$DIRECT_URL" in
*pgbouncer=true*)
echo "error: DIRECT_URL must NOT include 'pgbouncer=true'. Prisma Migrate cannot run through PgBouncer." >&2
echo " Configure DATABASE_URL to point at the pooler instead." >&2
exit 1
;;
*":6432/"*)
echo "warning: DIRECT_URL targets port 6432 (PgBouncer's port). Migrations will likely fail." >&2
echo " Expected an unpooled Postgres URL on port 5432." >&2
;;
esac
if [[ "$skip_ping" -eq 1 ]]; then
exit 0
fi
# Prefer `prisma migrate status` because it exercises the same driver path
# the migration commands use; fall back to a Node tcp ping so we don't hard-
# require prisma to be installed when this script runs as a pre-deploy hook.
if command -v pnpm >/dev/null 2>&1; then
if pnpm exec prisma migrate status >/dev/null 2>&1; then
echo "ok: prisma migrate status passed against DIRECT_URL"
exit 0
fi
echo "warning: prisma migrate status failed (DB may not be migrated yet)." >&2
fi
echo "ok: DIRECT_URL present and well-formed"
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Lokaler Dev-Launcher (sauber, reproduzierbar).
#
# Warum noetig: `react-router dev` (Vite) laedt `.env`/`.env.local` NICHT in die SSR-`process.env`
# (nur VITE_-prefixed in den Client). Die App-Config-Validierung liest aber `process.env`
# (DATABASE_URL/APP_NAME/SECRETS ...). Dieser Launcher laedt `.env` + `.env.local` in die echte
# Shell-Umgebung, stellt Redis sicher und startet dann `pnpm dev`.
#
# Lokale Overrides (DB-Port, Host:3000) gehoeren in `.env.local` (gitignored, siehe .env.local.example).
#
# Nutzung: bash scripts/dev/local.sh
# Dann: http://app.localhost:3000 (admin: http://admin.localhost:3000)
set -uo pipefail
# Repo-Root (zwei Ebenen ueber dieser Datei)
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT"
# Node 22.12.0 via fnm (falls vorhanden)
if command -v fnm >/dev/null 2>&1; then
eval "$(fnm env)"
fnm use 22.12.0 >/dev/null 2>&1 || true
fi
# Env laden: zuerst .env (SOPS-pull), dann .env.local (lokale Overrides gewinnen)
set -a
# shellcheck disable=SC1091
[ -f .env ] && . ./.env
# shellcheck disable=SC1091
[ -f .env.local ] && . ./.env.local
set +a
if [ -z "${DATABASE_URL:-}" ]; then
echo "FEHLER: DATABASE_URL nicht gesetzt. Erst 'pnpm env:pull' ausfuehren (.env) und ggf. .env.local anlegen." >&2
exit 1
fi
# Lokales Redis (BullMQ / Session-Cache) sicherstellen
if ! nc -z localhost 6379 >/dev/null 2>&1; then
docker start hg-redis >/dev/null 2>&1 \
|| docker run -d --name hg-redis -p 6379:6379 redis:7-alpine >/dev/null 2>&1 \
|| echo "WARN: konnte Redis nicht starten (BullMQ/Session-Cache evtl. eingeschraenkt)." >&2
fi
echo "Lokaler Dev-Server: http://app.localhost:3000 (admin: http://admin.localhost:3000)"
exec pnpm dev
+50
View File
@@ -0,0 +1,50 @@
import { chromium } from "playwright";
const BASE="http://app.localhost:3000"; const R=[];
const ck=(n,ok,d="")=>R.push(`${ok?"✓":"✗"} ${n}${d?" — "+d:""}`);
const b=await chromium.launch(); const ctx=await b.newContext({viewport:{width:1440,height:900}}); const p=await ctx.newPage();
const errs=[]; p.on("pageerror",e=>errs.push("PAGEERR: "+e.message)); p.on("console",m=>{if(m.type()==="error")errs.push("console: "+m.text().slice(0,90));});
const open=async()=>{await p.locator('[data-ui-id="ui.action.shell.more"]').click(); await p.waitForTimeout(400);};
const dash=async()=>{await p.goto(`${BASE}/e2e-readiness/dashboard`,{waitUntil:"networkidle"}); await p.waitForTimeout(400);};
await p.goto(`${BASE}/login`,{waitUntil:"domcontentloaded"});
await p.fill('input[name="email"]',"e2e.readiness@example.test"); await p.fill('input[name="password"]',"ReadinessPass123!");
await p.click('button[type="submit"]'); await p.waitForTimeout(1500); await dash();
// collapse
const sb=p.locator('[data-ui-id="ui.surface.app.sidebar"]'); const w0=(await sb.boundingBox())?.width??0;
await p.locator('[data-ui-id="ui.surface.app.sidebar"] button').first().click(); await p.waitForTimeout(450);
const w1=(await sb.boundingBox())?.width??0; ck("Collapse",w1<w0-50,`${Math.round(w0)}->${Math.round(w1)}`);
await p.locator('[data-ui-id="ui.surface.app.sidebar"] button').first().click(); await p.waitForTimeout(450);
const w2=(await sb.boundingBox())?.width??0; ck("Expand",w2>w1+50,`${Math.round(w1)}->${Math.round(w2)}`);
// search
await p.locator('[data-ui-id="ui.action.shell.search"]').click(); await p.waitForTimeout(500);
ck("Suche->Palette",await p.locator('[data-ui-id^="ui.surface.commandPalette"],[cmdk-root],[role="dialog"]').first().isVisible().catch(()=>false));
await p.keyboard.press("Escape"); await p.waitForTimeout(300);
// theme toggle (regex fix)
const hb=await p.locator("html").getAttribute("class")??""; await open();
const ti=p.getByText(/dunkl|hell|dark mode|light mode/i).first();
const tfound=await ti.isVisible().catch(()=>false); ck("Theme-Item da",tfound);
if(tfound){await ti.click(); await p.waitForTimeout(1500);}
const ha=await p.locator("html").getAttribute("class")??""; ck("Theme aendert html",hb!==ha,`"${hb}"->"${ha}"`);
// profil
await dash(); await open();
await p.locator('[data-ui-id="ui.action.shell.profile"]').click(); await p.waitForTimeout(900);
ck("Profil navigiert",/settings\/profile/.test(p.url()),p.url().replace(BASE,""));
// tenant switch
await dash(); await open();
const nest=p.getByText(/Profil ausw|Arbeitsbereich|wechseln/i).first();
if(await nest.isVisible().catch(()=>false)){await nest.click(); await p.waitForTimeout(500);
const wagner=p.getByText(/Wagner|Klein/).first();
if(await wagner.isVisible().catch(()=>false)){await wagner.click(); await p.waitForTimeout(1200); ck("Tenant-Switch",/wagner|klein/i.test(p.url()),p.url().replace(BASE,""));}
else ck("Tenant-Eintrag sichtbar",false);
} else ck("Tenant-Nested sichtbar",false);
// bell
await dash(); await p.locator('[data-ui-id="ui.action.shell.notifications"]').click(); await p.waitForTimeout(800);
ck("Glocke->Notifications",/notifications/.test(p.url()),p.url().replace(BASE,""));
// nav
await dash(); await p.locator('[data-ui-id="ui.nav.app.crm"]').first().click(); await p.waitForTimeout(800);
ck("Nav CRM",/\/crm/.test(p.url()),p.url().replace(BASE,""));
// logout LAST
await dash(); await open();
await p.locator('[data-ui-id="ui.action.shell.signOut"]').click(); await p.waitForTimeout(1500);
ck("Logout",/\/login|:3000\/?$/.test(p.url()),p.url().replace(BASE,""));
console.log(R.join("\n")); console.log("ERRORS:",errs.length);
await b.close();
+52
View File
@@ -0,0 +1,52 @@
import { chromium } from "playwright";
import { mkdirSync } from "node:fs";
const BASE = "http://app.localhost:3000";
const EMAIL = "e2e.readiness@example.test";
const PASSWORD = "ReadinessPass123!";
const OUT = "/tmp/shots";
mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
const consoleErrors = [];
page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
page.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.fill('input[name="email"]', EMAIL);
await page.fill('input[name="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL(/dashboard|select-tenant|\/[a-z0-9-]+\//, { timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1200);
const landing = page.url();
console.log("landing:", landing);
// Tenant-Slug aus der Landing-URL ableiten (host-aware: app.localhost:3000/<slug>/...)
const m = landing.match(/app\.localhost:3000\/([a-z0-9-]+)\//);
const tenant = "e2e-readiness"; void m;
console.log("tenant:", tenant);
const screens = [
["dashboard", `/${tenant}/dashboard`],
["crm", `/${tenant}/crm`],
["email-marketing", `/${tenant}/email-marketing`],
["workflow-engine", `/${tenant}/workflow-engine`],
["notifications", `/${tenant}/notifications`],
["settings-members", `/${tenant}/settings/members`],
["settings-subscription", `/${tenant}/settings/subscription`],
["settings-account", `/${tenant}/settings/account`],
];
for (const [name, path] of screens) {
await page.goto(`${BASE}${path}`, { waitUntil: "networkidle", timeout: 20000 }).catch((e) => console.log(`goto FAIL ${name}: ${e.message}`));
await page.waitForTimeout(700);
const redirected = /\/login/.test(page.url());
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: true });
console.log(`shot ${name} <- ${page.url()}${redirected ? " [REDIRECTED TO LOGIN]" : ""}`);
}
console.log("=== CONSOLE ERRORS (" + consoleErrors.length + ") ===");
for (const e of [...new Set(consoleErrors)].slice(0, 30)) console.log(" " + e.slice(0, 160));
await browser.close();
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${1:-toolctrl-api:smoke}"
PORT="${DOCKER_API_SMOKE_PORT:-}"
CONTAINER_NAME="${DOCKER_API_SMOKE_CONTAINER:-toolctrl-api-runtime-smoke-$$}"
DB_CONTAINER_NAME="${DOCKER_API_SMOKE_DB_CONTAINER:-toolctrl-api-runtime-smoke-db-$$}"
NETWORK_NAME="${DOCKER_API_SMOKE_NETWORK:-toolctrl-api-runtime-smoke-$$}"
TRANSPORT="${DOCKER_API_SMOKE_TRANSPORT:-host}"
SMOKE_REVISION="${DOCKER_API_SMOKE_REVISION:-}"
HEALTH_ATTEMPTS="${DOCKER_API_SMOKE_HEALTH_ATTEMPTS:-300}"
DB_PASSWORD="toolctrl"
DB_NAME="toolctrl"
DB_USER="toolctrl"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$DB_CONTAINER_NAME" >/dev/null 2>&1 || true
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
}
resolve_smoke_revision() {
if [[ -n "$SMOKE_REVISION" ]]; then
printf "%s" "$SMOKE_REVISION"
return
fi
docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE"
}
http_status() {
local path="$1"
local code
if [[ "$TRANSPORT" == "exec" ]]; then
code="$(docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: "api.localhost" } }, (response) => { process.stdout.write(String(response.statusCode ?? "000")); response.resume(); }); request.on("error", () => process.stdout.write("000")); request.end();' "$path" || true)"
else
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: api.localhost" "http://127.0.0.1:${PORT}${path}" || true)"
fi
if [[ -z "$code" ]]; then
printf "000"
return
fi
printf "%s" "$code"
}
http_body() {
local path="$1"
if [[ "$TRANSPORT" == "exec" ]]; then
docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: "api.localhost" } }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); response.on("end", () => process.stdout.write(body)); }); request.on("error", () => process.exit(1)); request.end();' "$path"
else
curl --fail --silent --show-error -H "Host: api.localhost" "http://127.0.0.1:${PORT}${path}"
fi
}
expect_status() {
local path="$1"
local expected="$2"
local actual
actual="$(http_status "$path")"
if [[ "$actual" != "$expected" ]]; then
echo "Expected ${path} to return ${expected}, got ${actual}" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
expect_health_revision() {
local path="$1"
local body
local actual
body="$(http_body "$path")"
actual="$(printf "%s" "$body" | node -e 'const fs = require("node:fs"); try { const payload = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(payload.revision || ""); } catch { process.exit(1); }')"
if [[ "$actual" != "$SMOKE_REVISION" ]]; then
echo "Expected ${path} revision ${SMOKE_REVISION}, got ${actual:-<empty>}" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
expect_openapi_json() {
local path="$1"
local body
body="$(http_body "$path")"
printf "%s" "$body" | node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
if (typeof payload.openapi !== "string") throw new Error("missing openapi version");
if (!payload.paths || !payload.paths["/v1/health"] || !payload.paths["/v1/entities/{entity}/rows"]) {
throw new Error("missing expected OpenAPI paths");
}
'
}
trap cleanup EXIT
cleanup
SMOKE_REVISION="$(resolve_smoke_revision)"
if [[ -z "$SMOKE_REVISION" || "$SMOKE_REVISION" == "<no value>" || "$SMOKE_REVISION" == "unknown" ]]; then
echo "Image ${IMAGE} does not expose a concrete org.opencontainers.image.revision; build with --build-arg APP_REVISION or set DOCKER_API_SMOKE_REVISION explicitly" >&2
exit 1
fi
docker network create "$NETWORK_NAME" >/dev/null
docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db -p 127.0.0.1::5432 \
-e POSTGRES_USER="$DB_USER" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
postgres:16-alpine >/dev/null
CONTAINER_DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
for attempt in $(seq 1 30); do
if docker exec "$DB_CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
break
fi
if [[ "$attempt" == "30" ]]; then
echo "Postgres smoke database did not become ready" >&2
docker logs "$DB_CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
docker run --rm --network "$NETWORK_NAME" \
-e NODE_ENV=production \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
--entrypoint /bin/sh \
"$IMAGE" -lc 'cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts' >/dev/null
publish_args=(-p 127.0.0.1::8080)
if [[ -n "$PORT" ]]; then
publish_args=(-p "${PORT}:8080")
fi
docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" "${publish_args[@]}" \
-e NODE_ENV=production \
-e PORT=8080 \
-e API_PORT=8080 \
-e APP_NAME=ToolCTRL \
-e APP_BASE_DOMAIN=localhost \
-e WEB_APP_HOST=app.localhost \
-e ADMIN_APP_HOST=admin.localhost \
-e API_HOST=api.localhost \
-e APP_PROTOCOL=http \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e SESSION_SECRET='deploy-smoke-session-secret-minimum-32' \
-e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \
-e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \
-e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \
-e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \
"$IMAGE" >/dev/null
if [[ -z "$PORT" ]]; then
PORT="$(docker port "$CONTAINER_NAME" 8080/tcp | awk -F: 'NR == 1 { print $NF }')"
fi
for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do
if [[ "$(http_status /healthz)" == "200" ]]; then
break
fi
if [[ "$attempt" == "$HEALTH_ATTEMPTS" ]]; then
echo "API container did not become healthy on port ${PORT} after ${HEALTH_ATTEMPTS}s" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
expect_status /healthz 200
expect_health_revision /healthz
expect_status /v1/docs 200
expect_status /v1/openapi.json 200
expect_openapi_json /v1/openapi.json
expect_status /readyz 200
echo "Docker API runtime smoke passed for ${IMAGE}"
@@ -0,0 +1,89 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const smokeScripts = [
"scripts/docker-web-runtime-smoke.sh",
"scripts/docker-api-runtime-smoke.sh",
"scripts/docker-worker-runtime-smoke.sh",
];
describe("Docker runtime smoke contract", () => {
it.each(smokeScripts)("reads the expected revision from the built image label: %s", (relativePath) => {
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
assert.match(source, /docker image inspect -f '\{\{ index \.Config\.Labels "org\.opencontainers\.image\.revision" \}\}' "\$IMAGE"/);
assert.match(source, /SMOKE_REVISION="\$\(resolve_smoke_revision\)"/);
});
it.each(smokeScripts)("rejects Dockerfile's default unknown revision: %s", (relativePath) => {
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
assert.match(source, /\$SMOKE_REVISION" == "unknown"/);
assert.match(source, /does not expose a concrete org\.opencontainers\.image\.revision/);
});
it.each(smokeScripts)("does not override APP_REVISION at container runtime: %s", (relativePath) => {
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
assert.doesNotMatch(source, /-e APP_REVISION=/);
});
it.each(["scripts/docker-web-runtime-smoke.sh", "scripts/docker-api-runtime-smoke.sh"])("validates the runtime OpenAPI JSON contract: %s", (relativePath) => {
const source = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
assert.match(source, /expect_openapi_json/);
assert.match(source, /\/v1\/openapi\.json/);
assert.match(source, /payload\.paths\["\/v1\/entities\/\{entity\}\/rows"\]/);
assert.doesNotMatch(source, /payload\.paths\["\/v1\/workflows\/run\/\{id\}"\]/);
});
it("runs the Coolify web predeploy command inside the final web image", () => {
const source = fs.readFileSync(path.join(rootDir, "scripts/docker-web-runtime-smoke.sh"), "utf8");
assert.match(source, /docker run --rm --network "\$NETWORK_NAME"/);
assert.match(source, /--entrypoint \/bin\/sh/);
assert.match(source, /pnpm exec prisma migrate deploy --config prisma\.config\.ts/);
assert.match(source, /NODE_ENV=production node packages\/db\/dist\/production-seed\.server\.js/);
assert.doesNotMatch(source, /DATABASE_URL="\$HOST_DATABASE_URL" pnpm exec prisma migrate deploy/);
});
it("runs the API smoke migration inside the final API image network", () => {
const source = fs.readFileSync(path.join(rootDir, "scripts/docker-api-runtime-smoke.sh"), "utf8");
assert.match(source, /docker run --rm --network "\$NETWORK_NAME"/);
assert.match(source, /-e DATABASE_URL="\$CONTAINER_DATABASE_URL"/);
assert.match(source, /--entrypoint \/bin\/sh/);
assert.match(source, /pnpm exec prisma migrate deploy --config prisma\.config\.ts/);
assert.doesNotMatch(source, /HOST_DATABASE_URL/);
assert.doesNotMatch(source, /DATABASE_URL="\$HOST_DATABASE_URL" pnpm exec prisma migrate deploy/);
});
it("runs the worker smoke migration inside the final worker image network", () => {
const source = fs.readFileSync(path.join(rootDir, "scripts/docker-worker-runtime-smoke.sh"), "utf8");
assert.match(source, /docker run --rm --network "\$NETWORK_NAME"/);
assert.match(source, /-e DATABASE_URL="\$CONTAINER_DATABASE_URL"/);
assert.match(source, /--entrypoint \/bin\/sh/);
assert.match(source, /pnpm exec prisma migrate deploy --config prisma\.config\.ts/);
assert.doesNotMatch(source, /HOST_DATABASE_URL/);
assert.doesNotMatch(source, /DATABASE_URL="\$HOST_DATABASE_URL" pnpm exec prisma migrate deploy/);
});
it("proves worker queue-group routing for general and mail workers", () => {
const source = fs.readFileSync(path.join(rootDir, "scripts/docker-worker-runtime-smoke.sh"), "utf8");
assert.match(source, /-e WORKER_QUEUE_GROUP=general/);
assert.match(source, /-e WORKER_QUEUE_GROUP=mail/);
assert.match(source, /expect_worker_health "\$enabled_health_output" ready true general toolctrl\.notifications/);
assert.match(source, /expect_worker_health "\$mail_health_output" ready true mail toolctrl\.email/);
assert.match(source, /report\.workerGroup !== expectedGroup/);
assert.match(source, /report\.queues\.includes\(expectedQueue\)/);
});
});
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${1:-toolctrl-web:smoke}"
PORT="${DOCKER_WEB_SMOKE_PORT:-}"
CONTAINER_NAME="${DOCKER_WEB_SMOKE_CONTAINER:-toolctrl-web-runtime-smoke-$$}"
DB_CONTAINER_NAME="${DOCKER_WEB_SMOKE_DB_CONTAINER:-toolctrl-web-runtime-smoke-db-$$}"
NETWORK_NAME="${DOCKER_WEB_SMOKE_NETWORK:-toolctrl-web-runtime-smoke-$$}"
TRANSPORT="${DOCKER_WEB_SMOKE_TRANSPORT:-host}"
SMOKE_REVISION="${DOCKER_WEB_SMOKE_REVISION:-}"
HEALTH_ATTEMPTS="${DOCKER_WEB_SMOKE_HEALTH_ATTEMPTS:-300}"
DB_PASSWORD="toolctrl"
DB_NAME="toolctrl"
DB_USER="toolctrl"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$DB_CONTAINER_NAME" >/dev/null 2>&1 || true
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
}
resolve_smoke_revision() {
if [[ -n "$SMOKE_REVISION" ]]; then
printf "%s" "$SMOKE_REVISION"
return
fi
docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE"
}
http_status() {
local host="$1"
local path="$2"
local code
if [[ "$TRANSPORT" == "exec" ]]; then
code="$(docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (response) => { process.stdout.write(String(response.statusCode ?? "000")); response.resume(); }); request.on("error", () => process.stdout.write("000")); request.end();' "$host" "$path" || true)"
else
code="$(curl -s -o /dev/null -w "%{http_code}" -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}" || true)"
fi
if [[ -z "$code" ]]; then
printf "000"
return
fi
printf "%s" "$code"
}
http_body() {
local host="$1"
local path="$2"
if [[ "$TRANSPORT" == "exec" ]]; then
docker exec "$CONTAINER_NAME" node -e 'const http = require("node:http"); const [host, path] = process.argv.slice(1); const request = http.request({ hostname: "127.0.0.1", port: 8080, path, headers: { Host: host } }, (response) => { let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); response.on("end", () => process.stdout.write(body)); }); request.on("error", () => process.exit(1)); request.end();' "$host" "$path"
else
curl --fail --silent --show-error -H "Host: ${host}" "http://127.0.0.1:${PORT}${path}"
fi
}
expect_status() {
local host="$1"
local path="$2"
local expected="$3"
local actual
actual="$(http_status "$host" "$path")"
if [[ "$actual" != "$expected" ]]; then
echo "Expected ${host}${path} to return ${expected}, got ${actual}" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
expect_health_revision() {
local host="$1"
local path="$2"
local body
local actual
body="$(http_body "$host" "$path")"
actual="$(printf "%s" "$body" | node -e 'const fs = require("node:fs"); try { const payload = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(payload.revision || ""); } catch { process.exit(1); }')"
if [[ "$actual" != "$SMOKE_REVISION" ]]; then
echo "Expected ${host}${path} revision ${SMOKE_REVISION}, got ${actual:-<empty>}" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
expect_openapi_json() {
local host="$1"
local path="$2"
local body
body="$(http_body "$host" "$path")"
printf "%s" "$body" | node -e '
const fs = require("node:fs");
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
if (typeof payload.openapi !== "string") throw new Error("missing openapi version");
if (!payload.paths || !payload.paths["/v1/health"] || !payload.paths["/v1/entities/{entity}/rows"]) {
throw new Error("missing expected OpenAPI paths");
}
'
}
trap cleanup EXIT
cleanup
SMOKE_REVISION="$(resolve_smoke_revision)"
if [[ -z "$SMOKE_REVISION" || "$SMOKE_REVISION" == "<no value>" || "$SMOKE_REVISION" == "unknown" ]]; then
echo "Image ${IMAGE} does not expose a concrete org.opencontainers.image.revision; build with --build-arg APP_REVISION or set DOCKER_WEB_SMOKE_REVISION explicitly" >&2
exit 1
fi
docker network create "$NETWORK_NAME" >/dev/null
docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db -p 127.0.0.1::5432 \
-e POSTGRES_USER="$DB_USER" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
postgres:16-alpine >/dev/null
DB_PORT="$(docker port "$DB_CONTAINER_NAME" 5432/tcp | awk -F: 'NR == 1 { print $NF }')"
CONTAINER_DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
for attempt in $(seq 1 30); do
if docker exec "$DB_CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
break
fi
if [[ "$attempt" == "30" ]]; then
echo "Postgres smoke database did not become ready" >&2
docker logs "$DB_CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
docker run --rm --network "$NETWORK_NAME" \
-e NODE_ENV=production \
-e APP_NAME=ToolCTRL \
-e APP_BASE_DOMAIN=localhost \
-e WEB_APP_HOST=app.localhost \
-e ADMIN_APP_HOST=admin.localhost \
-e API_HOST=api.localhost \
-e APP_PROTOCOL=http \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e SESSION_SECRET='deploy-smoke-session-secret-minimum-32' \
-e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \
-e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \
-e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \
-e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \
-e SEED_ADMIN_EMAIL='deploy-smoke-admin@example.test' \
-e SEED_ADMIN_PASSWORD='deploy-smoke-password-minimum-32' \
--entrypoint /bin/sh \
"$IMAGE" -lc 'cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts && NODE_ENV=production node packages/db/dist/production-seed.server.js' >/dev/null
publish_args=(-p 127.0.0.1::8080)
if [[ -n "$PORT" ]]; then
publish_args=(-p "${PORT}:8080")
fi
docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" "${publish_args[@]}" \
-e NODE_ENV=production \
-e PORT=8080 \
-e APP_NAME=ToolCTRL \
-e APP_BASE_DOMAIN=localhost \
-e WEB_APP_HOST=app.localhost \
-e ADMIN_APP_HOST=admin.localhost \
-e API_HOST=api.localhost \
-e APP_PROTOCOL=http \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e SESSION_SECRET='deploy-smoke-session-secret-minimum-32' \
-e CRYPTO_SECRET='deploy-smoke-crypto-secret-minimum-32' \
-e JWT_SECRET='deploy-smoke-jwt-secret-minimum-32' \
-e API_ACCESS_TOKEN='deploy-smoke-api-access-token-minimum-32' \
-e ANALYTICS_TOKEN='deploy-smoke-analytics-token-minimum-32' \
"$IMAGE" >/dev/null
if [[ -z "$PORT" ]]; then
PORT="$(docker port "$CONTAINER_NAME" 8080/tcp | awk -F: 'NR == 1 { print $NF }')"
fi
for attempt in $(seq 1 "$HEALTH_ATTEMPTS"); do
if [[ "$(http_status app.localhost /healthz)" == "200" ]]; then
break
fi
if [[ "$attempt" == "$HEALTH_ATTEMPTS" ]]; then
echo "Web container did not become healthy on port ${PORT} after ${HEALTH_ATTEMPTS}s" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
expect_status app.localhost /healthz 200
expect_health_revision app.localhost /healthz
expect_status app.localhost /readyz 200
expect_status api.localhost /v1/docs 200
expect_status api.localhost /v1/openapi.json 200
expect_openapi_json api.localhost /v1/openapi.json
expect_status app.localhost /app/acme/settings 404
expect_status admin.localhost /settings 302
echo "Docker web runtime smoke passed for ${IMAGE}"
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${1:-toolctrl-worker:smoke}"
CONTAINER_NAME="${DOCKER_WORKER_SMOKE_CONTAINER:-toolctrl-worker-runtime-smoke-$$}"
DB_CONTAINER_NAME="${DOCKER_WORKER_SMOKE_DB_CONTAINER:-toolctrl-worker-runtime-smoke-db-$$}"
REDIS_CONTAINER_NAME="${DOCKER_WORKER_SMOKE_REDIS_CONTAINER:-toolctrl-worker-runtime-smoke-redis-$$}"
NETWORK_NAME="${DOCKER_WORKER_SMOKE_NETWORK:-toolctrl-worker-runtime-smoke-$$}"
SMOKE_REVISION="${DOCKER_WORKER_SMOKE_REVISION:-}"
DB_PASSWORD="toolctrl"
DB_NAME="toolctrl"
DB_USER="toolctrl"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$DB_CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$REDIS_CONTAINER_NAME" >/dev/null 2>&1 || true
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
}
resolve_smoke_revision() {
if [[ -n "$SMOKE_REVISION" ]]; then
printf "%s" "$SMOKE_REVISION"
return
fi
docker image inspect -f '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$IMAGE"
}
trap cleanup EXIT
cleanup
SMOKE_REVISION="$(resolve_smoke_revision)"
if [[ -z "$SMOKE_REVISION" || "$SMOKE_REVISION" == "<no value>" || "$SMOKE_REVISION" == "unknown" ]]; then
echo "Image ${IMAGE} does not expose a concrete org.opencontainers.image.revision; build with --build-arg APP_REVISION or set DOCKER_WORKER_SMOKE_REVISION explicitly" >&2
exit 1
fi
worker_env=(
-e NODE_ENV=production
-e APP_NAME=ToolCTRL
-e APP_BASE_DOMAIN=localhost
)
expect_worker_health() {
local output="$1"
local expected_status="$2"
local expected_enabled="$3"
local expected_group="${4:-}"
local expected_queue="${5:-}"
node -e '
const [raw, expectedStatus, expectedEnabled, expectedRevision, expectedGroup, expectedQueue] = process.argv.slice(1);
const report = JSON.parse(raw);
if (report.status !== expectedStatus) {
console.error(`Expected worker status ${expectedStatus}, got ${report.status}`);
process.exit(1);
}
if (String(report.workerEnabled) !== expectedEnabled) {
console.error(`Expected workerEnabled ${expectedEnabled}, got ${report.workerEnabled}`);
process.exit(1);
}
if (report.revision !== expectedRevision) {
console.error(`Expected revision ${expectedRevision}, got ${report.revision || "<empty>"}`);
process.exit(1);
}
if (expectedGroup && report.workerGroup !== expectedGroup) {
console.error(`Expected workerGroup ${expectedGroup}, got ${report.workerGroup || "<empty>"}`);
process.exit(1);
}
if (expectedQueue && !Array.isArray(report.queues)) {
console.error("Expected worker health queues to be an array");
process.exit(1);
}
if (expectedQueue && !report.queues.includes(expectedQueue)) {
console.error(`Expected worker queues to include ${expectedQueue}, got ${report.queues?.join(",") || "<empty>"}`);
process.exit(1);
}
if (expectedStatus === "ready" && (report.databaseStatus !== "ready" || report.redisStatus !== "ready")) {
console.error(`Expected ready DB/Redis, got database=${report.databaseStatus} redis=${report.redisStatus}`);
process.exit(1);
}
' "$output" "$expected_status" "$expected_enabled" "$SMOKE_REVISION" "$expected_group" "$expected_queue"
}
docker network create "$NETWORK_NAME" >/dev/null
health_output="$(
docker run --rm \
"${worker_env[@]}" \
-e WORKER_ENABLED=false \
"$IMAGE" node apps/worker/dist/health.js
)"
expect_worker_health "$health_output" disabled false
docker run -d --name "$DB_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias db \
-e POSTGRES_USER="$DB_USER" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB="$DB_NAME" \
postgres:16-alpine >/dev/null
docker run -d --name "$REDIS_CONTAINER_NAME" --network "$NETWORK_NAME" --network-alias redis redis:7-alpine >/dev/null
CONTAINER_DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}?schema=public"
CONTAINER_REDIS_URL="redis://redis:6379/0"
for attempt in $(seq 1 30); do
if docker exec "$DB_CONTAINER_NAME" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
break
fi
if [[ "$attempt" == "30" ]]; then
echo "Postgres smoke database did not become ready" >&2
docker logs "$DB_CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
for attempt in $(seq 1 30); do
if docker exec "$REDIS_CONTAINER_NAME" redis-cli ping >/dev/null 2>&1; then
break
fi
if [[ "$attempt" == "30" ]]; then
echo "Redis smoke broker did not become ready" >&2
docker logs "$REDIS_CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
docker run --rm --network "$NETWORK_NAME" \
-e NODE_ENV=production \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
--entrypoint /bin/sh \
"$IMAGE" -lc 'cd /myapp && pnpm exec prisma migrate deploy --config prisma.config.ts' >/dev/null
enabled_health_output="$(
docker run --rm --network "$NETWORK_NAME" \
"${worker_env[@]}" \
-e WORKER_ENABLED=true \
-e WORKER_QUEUE_GROUP=general \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e BULLMQ_REDIS_URL="$CONTAINER_REDIS_URL" \
"$IMAGE" node apps/worker/dist/health.js
)"
expect_worker_health "$enabled_health_output" ready true general toolctrl.notifications
mail_health_output="$(
docker run --rm --network "$NETWORK_NAME" \
"${worker_env[@]}" \
-e WORKER_ENABLED=true \
-e WORKER_QUEUE_GROUP=mail \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e BULLMQ_REDIS_URL="$CONTAINER_REDIS_URL" \
"$IMAGE" node apps/worker/dist/health.js
)"
expect_worker_health "$mail_health_output" ready true mail toolctrl.email
docker run -d --name "$CONTAINER_NAME" --network "$NETWORK_NAME" \
"${worker_env[@]}" \
-e WORKER_ENABLED=true \
-e WORKER_QUEUE_GROUP=general \
-e DATABASE_URL="$CONTAINER_DATABASE_URL" \
-e BULLMQ_REDIS_URL="$CONTAINER_REDIS_URL" \
"$IMAGE" >/tmp/toolctrl-worker-runtime-smoke.log
for attempt in $(seq 1 10); do
if [[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" == "true" ]]; then
break
fi
if [[ "$attempt" == "10" ]]; then
echo "Worker container did not stay running" >&2
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
sleep 1
done
running_health_output="$(
docker exec "$CONTAINER_NAME" node apps/worker/dist/health.js
)"
expect_worker_health "$running_health_output" ready true general toolctrl.notifications
echo "Docker worker runtime smoke passed for ${IMAGE}"
@@ -0,0 +1,127 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { afterEach, describe, it } from "vitest";
import { checkDocsLinks, formatDocsLinkCheckResult } from "./docs-link-check";
const fixtures: string[] = [];
function createFixture(files: Record<string, string>): string {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "docs-link-check-"));
fixtures.push(rootDir);
for (const [relativePath, content] of Object.entries(files)) {
const absolutePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, content);
}
return rootDir;
}
afterEach(() => {
while (fixtures.length > 0) {
fs.rmSync(fixtures.pop() as string, { recursive: true, force: true });
}
});
describe("docs-link-check", () => {
it("passes for valid local Markdown links and anchors", () => {
const rootDir = createFixture({
"README.md": "# Root\nSee [plugins](docs/plugins.md#plugin-boundaries).",
"docs/plugins.md": "# Plugin Boundaries\nSee [feature inventory](feature-inventory.md).",
"docs/feature-inventory.md": "# Feature Inventory\n",
});
const result = checkDocsLinks(rootDir, ["README.md", "docs"]);
const output = formatDocsLinkCheckResult(result);
assert.deepEqual(result.failures, []);
assert.match(output, /^PASS docs link check/);
});
it("fails with file context for missing local paths", () => {
const rootDir = createFixture({
"README.md": "# Root\nSee [missing](docs/missing.md).",
});
const result = checkDocsLinks(rootDir, ["README.md", "docs"]);
assert.match(result.failures.join("\n"), /README\.md:\d+:\d+: broken local link "docs\/missing\.md"/);
});
it("fails with file context for missing local anchors", () => {
const rootDir = createFixture({
"README.md": "# Root\nSee [plugins](docs/plugins.md#missing-anchor).",
"docs/plugins.md": "# Plugin Boundaries\n",
});
const result = checkDocsLinks(rootDir, ["README.md", "docs"]);
assert.match(result.failures.join("\n"), /README\.md:\d+:\d+: broken local anchor "#missing-anchor"/);
});
it("ignores external URLs and special link schemes", () => {
const rootDir = createFixture({
"README.md": [
"# Root",
"See [external](https://example.com/missing#anchor).",
"Send [mail](mailto:help@example.com) or [call](tel:+15555550100).",
"Jump to [local](#root).",
].join("\n"),
});
const result = checkDocsLinks(rootDir, ["README.md"]);
assert.deepEqual(result.failures, []);
});
it("fails closed when a configured docs root is missing", () => {
const rootDir = createFixture({
"README.md": "# Root\n",
});
const result = checkDocsLinks(rootDir, ["README.md", "docs"]);
assert.match(result.failures.join("\n"), /docs-roots: missing configured docs root docs/);
});
it("fails closed when no Markdown files are checked", () => {
const rootDir = createFixture({
"src/index.ts": "export {};",
});
const result = checkDocsLinks(rootDir, ["docs"]);
assert.match(result.failures.join("\n"), /docs-roots: checked 0 Markdown files/);
});
it("exits non-zero from the CLI when local links are broken", () => {
const rootDir = createFixture({
"README.md": "# Root\nSee [missing](docs/missing.md).",
"AGENTS.md": "# Agents\n",
"SECURITY.md": "# Security\n",
"CHANGELOG.md": "# Changelog\n",
"docs/index.md": "# Docs\n",
"guides/index.md": "# Guides\n",
"llm-docs/index.md": "# LLM Docs\n",
});
let output = "";
assert.throws(() => {
try {
execFileSync("pnpm", ["exec", "tsx", path.resolve("scripts/docs-link-check.ts"), rootDir], { encoding: "utf8", stdio: "pipe" });
} catch (error) {
if (error && typeof error === "object") {
const failure = error as { stderr?: Buffer | string; stdout?: Buffer | string };
output = `${String(failure.stderr ?? "")}\n${String(failure.stdout ?? "")}`;
}
throw error;
}
});
assert.match(output, /broken local link "docs\/missing\.md"/);
});
});
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env tsx
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const DEFAULT_DOCS_ROOTS = ["README.md", "AGENTS.md", "SECURITY.md", "CHANGELOG.md", "docs", "guides", "llm-docs"];
const MARKDOWN_LINK_PATTERN = /!?\[([^\]\n]*(?:\][^\]\n]*)*)\]\(([^)\n]+)\)/g;
const HEADING_PATTERN = /^#{1,6}\s+(.+)$/gm;
function normalizeRelativePath(relativePath: string): string {
return relativePath.split(path.sep).join("/");
}
function lineAndColumnForIndex(content: string, index: number): { line: number; column: number } {
const prefix = content.slice(0, index);
const lines = prefix.split(/\r?\n/);
return { line: lines.length, column: (lines.at(-1) ?? "").length + 1 };
}
function collectMarkdownFiles(rootDir: string, docsRoots = DEFAULT_DOCS_ROOTS): { files: string[]; missingRoots: string[] } {
const files: string[] = [];
const missingRoots: string[] = [];
function visit(absolutePath: string): void {
const stat = fs.statSync(absolutePath);
if (stat.isDirectory()) {
for (const entry of fs.readdirSync(absolutePath).sort()) {
visit(path.join(absolutePath, entry));
}
return;
}
if (/\.mdx?$/i.test(absolutePath)) {
files.push(normalizeRelativePath(path.relative(rootDir, absolutePath)));
}
}
for (const root of docsRoots) {
const absolutePath = path.join(rootDir, root);
if (fs.existsSync(absolutePath)) {
visit(absolutePath);
} else {
missingRoots.push(root);
}
}
return { files, missingRoots };
}
function slugifyHeading(heading: string): string {
return heading
.replace(/`([^`]+)`/g, "$1")
.replace(/<[^>]+>/g, "")
.trim()
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
}
function anchorsFor(content: string): Set<string> {
const anchors = new Set<string>();
const seen = new Map<string, number>();
for (const match of content.matchAll(HEADING_PATTERN)) {
const raw = match[1].replace(/#+\s*$/, "").trim();
const base = slugifyHeading(raw);
if (!base) continue;
const count = seen.get(base) ?? 0;
seen.set(base, count + 1);
anchors.add(count === 0 ? base : `${base}-${count}`);
}
return anchors;
}
function isExternalOrSpecialTarget(target: string): boolean {
return /^(?:[a-z][a-z0-9+.-]*:|#|mailto:|tel:)/i.test(target);
}
function stripQueryAndHash(target: string): { targetPath: string; anchor: string } {
const hashIndex = target.indexOf("#");
const beforeHash = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
const anchor = hashIndex >= 0 ? decodeURIComponent(target.slice(hashIndex + 1)) : "";
const queryIndex = beforeHash.indexOf("?");
const targetPath = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
return { targetPath, anchor };
}
export function checkDocsLinks(rootDir = process.cwd(), docsRoots = DEFAULT_DOCS_ROOTS): { failures: string[]; checkedFiles: string[] } {
const { files: checkedFiles, missingRoots } = collectMarkdownFiles(rootDir, docsRoots);
const contentByPath = new Map<string, string>();
const anchorByPath = new Map<string, Set<string>>();
for (const relativePath of checkedFiles) {
const content = fs.readFileSync(path.join(rootDir, relativePath), "utf8");
contentByPath.set(relativePath, content);
anchorByPath.set(relativePath, anchorsFor(content));
}
const failures: string[] = [];
for (const missingRoot of missingRoots) {
failures.push(`docs-roots: missing configured docs root ${missingRoot}`);
}
if (checkedFiles.length === 0) {
failures.push(`docs-roots: checked 0 Markdown files for roots ${docsRoots.join(", ")}`);
return { failures, checkedFiles };
}
for (const [sourcePath, content] of contentByPath.entries()) {
for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) {
const rawTarget = match[2].trim();
if (!rawTarget || isExternalOrSpecialTarget(rawTarget)) continue;
const { targetPath, anchor } = stripQueryAndHash(rawTarget);
const sourcePosition = lineAndColumnForIndex(content, match.index ?? 0);
const sourceContext = `${sourcePath}:${sourcePosition.line}:${sourcePosition.column}`;
const resolvedPath = targetPath
? normalizeRelativePath(
path.normalize(
targetPath.startsWith("/")
? path.join("docs", decodeURIComponent(targetPath).slice(1))
: path.join(path.dirname(sourcePath), decodeURIComponent(targetPath)),
),
)
: sourcePath;
const absoluteTarget = path.join(rootDir, resolvedPath);
const targetExists = !targetPath || fs.existsSync(absoluteTarget);
const markdownExtension = path.extname(resolvedPath);
const extensionlessDocExists =
targetPath && !targetExists && markdownExtension === "" && fs.existsSync(`${absoluteTarget}.md`);
if (targetPath && !targetExists && !extensionlessDocExists) {
failures.push(`${sourceContext}: broken local link "${rawTarget}" resolves to missing path ${resolvedPath}`);
continue;
}
const finalAbsoluteTarget = extensionlessDocExists ? `${absoluteTarget}.md` : absoluteTarget;
const finalResolvedPath = extensionlessDocExists ? `${resolvedPath}.md` : resolvedPath;
if (targetPath && fs.statSync(finalAbsoluteTarget).isDirectory()) {
continue;
}
if (anchor) {
const targetContent = targetPath ? fs.readFileSync(finalAbsoluteTarget, "utf8") : content;
const targetAnchors = targetPath ? anchorsFor(targetContent) : anchorByPath.get(sourcePath) ?? new Set<string>();
if (!targetAnchors.has(anchor)) {
failures.push(`${sourceContext}: broken local anchor "#${anchor}" in ${finalResolvedPath}`);
}
}
}
}
return { failures, checkedFiles };
}
export function formatDocsLinkCheckResult(result: { failures: string[]; checkedFiles: string[] }): string {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
return [
`${status} docs link check`,
`OBSERVE checked ${result.checkedFiles.length} Markdown files`,
...result.failures.map((failure) => `FAIL ${failure}`),
`Summary: ${result.failures.length === 0 ? 1 : 0} passed, 1 observed, ${result.failures.length} failed`,
"",
].join("\n");
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const result = checkDocsLinks(process.argv[2] ? path.resolve(process.argv[2]) : process.cwd());
const output = formatDocsLinkCheckResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
}
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# Forward + rollback migration drill against an ephemeral PostgreSQL
# container. Validates that:
# 1. All committed migrations apply cleanly from an empty database
# 2. The seed runs idempotently against the resulting schema
# 3. Running the entire forward chain a second time is a no-op
# (Prisma marks every migration as already applied)
#
# This is a production-rehearsal: catches migrations that fail under a
# fresh DB (drift between dev shadow and production state) and asserts the
# seed-after-migrate contract that prod deploys rely on.
#
# Usage:
# ./scripts/drill-migration-rollback.sh
# ./scripts/drill-migration-rollback.sh --no-seed # skip seed step
# ./scripts/drill-migration-rollback.sh --keep-container # leave PG running
#
# Requires: docker, pnpm. Aborts with a documentation pointer if either
# is missing.
set -euo pipefail
DRILL_NAME="boilerplate-rollback-drill-$$"
DRILL_PORT="${DRILL_PG_PORT:-55842}"
DRILL_USER="drill"
DRILL_PASSWORD="drill"
DRILL_DB="drill"
DRILL_DATABASE_URL="postgresql://${DRILL_USER}:${DRILL_PASSWORD}@127.0.0.1:${DRILL_PORT}/${DRILL_DB}?schema=public"
KEEP_CONTAINER=0
RUN_SEED=1
PG_IMAGE="${DRILL_PG_IMAGE:-postgres:16-alpine}"
for arg in "$@"; do
case "$arg" in
--no-seed) RUN_SEED=0 ;;
--keep-container) KEEP_CONTAINER=1 ;;
--help|-h)
sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
exit 0
;;
*) echo "Unknown arg: $arg" >&2; exit 64 ;;
esac
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "MISSING $1" >&2
echo "" >&2
echo "Migration-rollback drill requires '$1' to be available locally." >&2
echo "See docs/operations/database-rollback.md for the manual procedure." >&2
exit 127
fi
}
require_cmd docker
require_cmd pnpm
cleanup() {
local exit_code=$?
if [ "$KEEP_CONTAINER" = "0" ]; then
docker rm -f "$DRILL_NAME" >/dev/null 2>&1 || true
else
echo ""
echo "KEEP container '$DRILL_NAME' is still running on port $DRILL_PORT (DATABASE_URL=$DRILL_DATABASE_URL)" >&2
fi
exit "$exit_code"
}
trap cleanup EXIT
echo "PHASE 0/4 starting ephemeral postgres ($PG_IMAGE on port $DRILL_PORT)"
docker run --rm -d \
--name "$DRILL_NAME" \
-p "${DRILL_PORT}:5432" \
-e POSTGRES_USER="$DRILL_USER" \
-e POSTGRES_PASSWORD="$DRILL_PASSWORD" \
-e POSTGRES_DB="$DRILL_DB" \
"$PG_IMAGE" >/dev/null
deadline=$(( $(date +%s) + 30 ))
until docker exec "$DRILL_NAME" pg_isready -U "$DRILL_USER" -d "$DRILL_DB" >/dev/null 2>&1; do
if [ "$(date +%s)" -gt "$deadline" ]; then
echo "FAIL postgres did not become ready within 30s" >&2
exit 1
fi
sleep 1
done
echo "PASS postgres ready"
echo "PHASE 1/4 applying forward migration chain"
DATABASE_URL="$DRILL_DATABASE_URL" pnpm --filter @toolctrl/db prisma:generate >/dev/null
DATABASE_URL="$DRILL_DATABASE_URL" pnpm exec prisma migrate deploy --schema prisma/schema/schema.prisma
forward_count=$(DATABASE_URL="$DRILL_DATABASE_URL" pnpm exec prisma migrate status --schema prisma/schema/schema.prisma 2>&1 | grep -c "Following migration have been applied" || true)
echo "PASS forward migration chain applied"
if [ "$RUN_SEED" = "1" ]; then
echo "PHASE 2/4 running production seed (idempotency probe 1/2)"
DATABASE_URL="$DRILL_DATABASE_URL" \
SEED_ADMIN_EMAIL="drill-admin@example.com" \
SEED_ADMIN_PASSWORD="drill-bootstrap-password-do-not-reuse" \
SEED_DEMO_DATA="false" \
NODE_ENV=production \
pnpm --filter @toolctrl/db seed
echo "PASS seed run 1"
echo "PHASE 3/4 re-running seed (idempotency probe 2/2)"
DATABASE_URL="$DRILL_DATABASE_URL" \
SEED_ADMIN_EMAIL="drill-admin@example.com" \
SEED_ADMIN_PASSWORD="drill-bootstrap-password-do-not-reuse" \
SEED_DEMO_DATA="false" \
NODE_ENV=production \
pnpm --filter @toolctrl/db seed
echo "PASS seed run 2 (idempotent)"
else
echo "SKIP seed (--no-seed flag)"
fi
echo "PHASE 4/4 re-applying migrate deploy (chain idempotency)"
output=$(DATABASE_URL="$DRILL_DATABASE_URL" pnpm exec prisma migrate deploy --schema prisma/schema/schema.prisma 2>&1)
echo "$output" | tail -5
if echo "$output" | grep -qiE "no pending migrations|already in sync|database schema is up to date"; then
echo "PASS migrate deploy is a no-op on already-migrated schema"
else
echo "FAIL second migrate deploy reported changes — chain is not idempotent" >&2
exit 1
fi
echo ""
echo "DRILL OK — migration chain + seed survive a fresh-database deployment rehearsal"
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { isBlockingAxeImpact, summarizeAxeViolations } from "../tests/e2e/utils/axe";
describe("summarizeAxeViolations", () => {
it("deduplicates repeated advisory violations while preserving node counts", () => {
const summary = summarizeAxeViolations([
{ id: "color-contrast", impact: "moderate", help: "Elements must meet contrast", nodes: [{}, {}] },
{ id: "color-contrast", impact: "moderate", help: "Elements must meet contrast", nodes: [{}] },
{ id: "image-alt", impact: "minor", help: "Images must have alternate text", nodes: [{}] },
]);
expect(summary).toEqual([
{ id: "color-contrast", impact: "moderate", help: "Elements must meet contrast", nodes: 3 },
{ id: "image-alt", impact: "minor", help: "Images must have alternate text", nodes: 1 },
]);
});
});
describe("isBlockingAxeImpact", () => {
it("blocks critical and serious violations by default", () => {
expect(isBlockingAxeImpact("critical", {} as NodeJS.ProcessEnv)).toBe(true);
expect(isBlockingAxeImpact("serious", {} as NodeJS.ProcessEnv)).toBe(true);
expect(isBlockingAxeImpact("moderate", {} as NodeJS.ProcessEnv)).toBe(false);
expect(isBlockingAxeImpact("minor", {} as NodeJS.ProcessEnv)).toBe(false);
});
it("also blocks moderate violations in strict readiness mode", () => {
expect(isBlockingAxeImpact("moderate", { E2E_A11Y_STRICT: "1" } as NodeJS.ProcessEnv)).toBe(true);
expect(isBlockingAxeImpact("minor", { E2E_A11Y_STRICT: "1" } as NodeJS.ProcessEnv)).toBe(false);
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
isE2eOperatorOptInEnabled,
missingE2eInputs,
optionalE2eBooleanInput,
skipOrFailWhenE2eFixtureMissing,
skipOrFailWhenE2eInputsMissing,
skipOrFailWhenE2eOptInMissing,
skipOrFailWhenE2eReadinessOptInMissing,
} from "../tests/e2e/utils/readiness";
describe("missingE2eInputs", () => {
it("treats unset, empty, and whitespace-only readiness inputs as missing", () => {
const missing = missingE2eInputs(["UNSET", "EMPTY", "SPACES", "PATH"], {
EMPTY: "",
SPACES: " \n\t",
PATH: "/e2e-readiness/dashboard",
} as NodeJS.ProcessEnv);
expect(missing).toEqual(["UNSET", "EMPTY", "SPACES"]);
});
});
describe("strict E2E readiness gates", () => {
it("requires explicit 0|1 boolean inputs in readiness mode", () => {
expect(() =>
optionalE2eBooleanInput("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", {
REQUIRE_E2E_READINESS: "1",
} as NodeJS.ProcessEnv)
).toThrow(/E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION=0\|1 must be set while REQUIRE_E2E_READINESS=1/u);
});
it("parses explicit 0|1 boolean inputs", () => {
expect(
optionalE2eBooleanInput("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", { E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0" } as NodeJS.ProcessEnv)
).toBe(false);
expect(
optionalE2eBooleanInput("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", { E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "1" } as NodeJS.ProcessEnv)
).toBe(true);
expect(optionalE2eBooleanInput("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", {} as NodeJS.ProcessEnv)).toBeUndefined();
});
it("rejects invalid boolean inputs", () => {
expect(() =>
optionalE2eBooleanInput("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION", {
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "yes",
} as NodeJS.ProcessEnv)
).toThrow(/E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION must be 0 or 1/u);
});
it("fails instead of skipping when required readiness inputs are missing", () => {
expect(() =>
skipOrFailWhenE2eInputsMissing(["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD"], "Tenant shell inputs not configured", {
E2E_TENANT_EMAIL: "tenant@example.test",
E2E_TENANT_PASSWORD: "",
REQUIRE_E2E_READINESS: "1",
} as NodeJS.ProcessEnv)
).toThrow(/Tenant shell inputs not configured: missing E2E_TENANT_PASSWORD while REQUIRE_E2E_READINESS=1/u);
});
it("fails instead of skipping when a mutating opt-in is missing in mutating readiness mode", () => {
expect(() =>
skipOrFailWhenE2eOptInMissing("E2E_ALLOW_REGISTRATION_CREATE", "skipping mutating registration provisioning flow", {
REQUIRE_E2E_READINESS: "1",
REQUIRE_E2E_MUTATING_READINESS: "1",
} as NodeJS.ProcessEnv)
).toThrow(/E2E_ALLOW_REGISTRATION_CREATE=1 not set - skipping mutating registration provisioning flow while REQUIRE_E2E_MUTATING_READINESS=1/u);
});
it("fails instead of skipping when a required readiness opt-in is missing in readiness mode", () => {
expect(() =>
skipOrFailWhenE2eReadinessOptInMissing("E2E_BROWSER_MATRIX", "browser matrix readiness is required", {
REQUIRE_E2E_READINESS: "1",
} as NodeJS.ProcessEnv)
).toThrow(/E2E_BROWSER_MATRIX=1 not set - browser matrix readiness is required while REQUIRE_E2E_READINESS=1/u);
});
it("fails instead of skipping when a fixture is missing in readiness mode", () => {
expect(() =>
skipOrFailWhenE2eFixtureMissing("No mobile shell subscription settings link rendered for this fixture", {
REQUIRE_E2E_READINESS: "1",
} as NodeJS.ProcessEnv)
).toThrow(/No mobile shell subscription settings link rendered for this fixture while REQUIRE_E2E_READINESS=1/u);
});
it("keeps operator-only probes opt-in even during strict readiness runs", () => {
expect(
isE2eOperatorOptInEnabled("E2E_REQUIRE_SUBSCRIPTION_SUCCESS", {
REQUIRE_E2E_READINESS: "1",
} as NodeJS.ProcessEnv)
).toBe(false);
expect(
isE2eOperatorOptInEnabled("E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN", {
REQUIRE_E2E_READINESS: "1",
E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "1",
} as NodeJS.ProcessEnv)
).toBe(true);
});
});
@@ -0,0 +1,32 @@
#!/usr/bin/env node
const { spawn } = require("node:child_process");
const { existsSync, readdirSync, statSync } = require("node:fs");
const { dirname, extname, join, relative, resolve } = require("node:path");
const repoRoot = process.env.TOOLCTRL_REPO_ROOT || resolve(dirname(__filename), process.env.ESLINT_PNPM_EXEC_REPO_ROOT_OFFSET ?? "../..");
const eslintEntry = resolve(dirname(require.resolve("eslint/package.json", { paths: [repoRoot] })), "bin/eslint.js");
const lintableExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
function expandDirectoryArgument(argument) {
const absolute = resolve(repoRoot, argument);
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) return [argument];
const files = readdirSync(absolute)
.map((entry) => join(absolute, entry))
.filter((entry) => statSync(entry).isFile() && lintableExtensions.has(extname(entry)))
.map((entry) => relative(repoRoot, entry));
return files.length > 0 ? files : [argument];
}
const args = process.argv.slice(2).flatMap(expandDirectoryArgument);
const child = spawn(process.execPath, [eslintEntry, ...args], { cwd: repoRoot, stdio: "inherit" });
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Wrapper invoked by the lefthook pre-commit `lint-staged` step. ESLint v8
// raises a "File ignored because of a matching ignore pattern" warning when an
// explicitly-passed path matches an ignorePatterns entry — which together with
// --max-warnings=0 makes every commit touching `apps/web/app/**` fail, since
// the root .eslintrc ignores `app/` by default. v9's --no-warn-ignored would
// fix this directly; until that upgrade lands we filter the file list against
// the ESLint API and only lint the files ESLint would actually inspect.
import { ESLint } from "eslint";
const files = process.argv.slice(2);
if (files.length === 0) {
process.exit(0);
}
const eslint = new ESLint({ errorOnUnmatchedPattern: false });
const candidates = [];
for (const file of files) {
if (!(await eslint.isPathIgnored(file))) {
candidates.push(file);
}
}
if (candidates.length === 0) {
process.exit(0);
}
const results = await eslint.lintFiles(candidates);
const formatter = await eslint.loadFormatter("stylish");
const output = await formatter.format(results);
if (output) {
process.stdout.write(output);
if (!output.endsWith("\n")) process.stdout.write("\n");
}
let errorCount = 0;
let warningCount = 0;
for (const result of results) {
errorCount += result.errorCount;
warningCount += result.warningCount;
}
if (errorCount > 0 || warningCount > 0) {
process.exit(1);
}
process.exit(0);
@@ -0,0 +1,2 @@
const target = "@toolctrl/" + "db";
await import(target);
@@ -0,0 +1,3 @@
export async function forbiddenDynamicImport() {
return import("@toolctrl/db");
}
@@ -0,0 +1,3 @@
import React from "react";
export const invalidFoundationImport = React.createElement("div");
@@ -0,0 +1,3 @@
import { getDbClient } from "@toolctrl/db/client.server";
export const invalidUiAppImport = getDbClient;
@@ -0,0 +1,3 @@
import { getDbClient } from "../../../packages/db/src/client.server";
export const client = getDbClient();
@@ -0,0 +1,2 @@
const target = "@toolctrl/db";
require(target);
@@ -0,0 +1,2 @@
const db = require("@toolctrl/db/client.server");
module.exports = db;
@@ -0,0 +1,3 @@
import { getDatabaseBoundary } from "@toolctrl/db";
export const invalidViewModelImport = getDatabaseBoundary();
@@ -0,0 +1,3 @@
export const one = 1;
export const two = 2;
export const three = 3;
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Generates a lightweight SBOM-style dependency manifest by shelling out to
// `pnpm list -r --json --depth=Infinity` and writing the result to
// `sbom-pnpm.json` in the repo root.
//
// Why pnpm-list instead of @cyclonedx/cyclonedx-npm:
// - cyclonedx-npm is opinionated about lockfile shape and tends to mis-handle
// pnpm workspaces (apps/*, packages/*). pnpm's own resolver is the source of
// truth, so we capture its output verbatim.
// - The artifact is consumed for audit traceability (which version of which
// package shipped in this image), not for CycloneDX validator gating. JSON
// tree is sufficient.
//
// The script is invoked from the Docker `build` stage and the resulting JSON
// is COPYed into each runtime stage so that the SBOM ships alongside the app.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const OUTPUT_PATH = process.env.SBOM_OUTPUT_PATH
? path.resolve(process.env.SBOM_OUTPUT_PATH)
: path.join(ROOT, "sbom-pnpm.json");
function runPnpmList() {
const result = spawnSync(
"pnpm",
["list", "-r", "--json", "--depth=Infinity"],
{ cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
const stderr = result.stderr?.toString() ?? "";
throw new Error(`pnpm list exited with status ${result.status}: ${stderr}`);
}
return result.stdout?.toString() ?? "";
}
function parseTree(raw) {
if (!raw.trim()) {
return [];
}
try {
return JSON.parse(raw);
} catch (err) {
throw new Error(`pnpm list output was not valid JSON: ${(err && err.message) || err}`);
}
}
function repoVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
return { name: pkg.name ?? null, version: pkg.version ?? null };
} catch {
return { name: null, version: null };
}
}
function main() {
const raw = runPnpmList();
const tree = parseTree(raw);
const repo = repoVersion();
const document = {
schema: "toolctrl.sbom-pnpm/v1",
generatedAt: new Date().toISOString(),
generator: "scripts/generate-sbom.mjs",
source: "pnpm list -r --json --depth=Infinity",
repo,
workspaces: tree,
};
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(document, null, 2));
// eslint-disable-next-line no-console
console.log(`SBOM written: ${OUTPUT_PATH} (${tree.length} workspace entries)`);
}
main();
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
// i18n-audit (#72 i18n-foundation).
//
// Scans key web UI roots for hardcoded English or German JSX text and emits a
// baseline Markdown report. This is BASELINE work — the script counts and
// lists; it does NOT enforce. Full extraction is a separately-tracked sprint.
//
// Usage:
// node scripts/i18n-audit.mjs # write to default path
// node scripts/i18n-audit.mjs --out path/to/report.md # custom out path
// node scripts/i18n-audit.mjs --json # stdout JSON
/* eslint-disable no-console */
import { readdir, readFile, stat, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const SCAN_ROOTS = ["routes", "modules", "routeModules", "components"].map((root) => path.join(repoRoot, "apps/web/app", root));
const IGNORED_DIRS = new Set(["node_modules", "dist", "build", ".cache", "coverage", "__tests__", "test-fixtures"]);
const IGNORED_FILE_SUFFIXES = [".test.tsx", ".test.ts", ".spec.tsx", ".spec.ts", ".stories.tsx", ".d.ts"];
// JSX text matcher. We look for runs of letters that are clearly user-facing
// — anything ≥ 3 chars, starts with a capital or contains a German umlaut, and
// is not wrapped in `{t(…)}`. The point isn't to be 100 % accurate; it's to
// produce a meaningful baseline number that the next extraction sprint can
// drive to zero.
const JSX_TEXT_PATTERN = />\s*([A-ZÄÖÜ][A-Za-zÄÖÜäöüß][A-Za-zÄÖÜäöüß0-9 .,;:!?'\-]{2,})\s*</g;
const INTL_FORMATTING_PATTERN = /\b(?:toLocale(?:String|DateString|TimeString|LowerCase|UpperCase)?|DateUtils\.[A-Za-z0-9_]+|NumberUtils\.[A-Za-z0-9_]+)\s*\(/g;
async function walk(dir) {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
for (const entry of entries) {
if (entry.name.startsWith(".")) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name)) continue;
out.push(...(await walk(full)));
continue;
}
if (!entry.name.endsWith(".tsx") && !entry.name.endsWith(".ts")) continue;
if (IGNORED_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) continue;
out.push(full);
}
return out;
}
function extractHardcodedStrings(source) {
const hits = [];
const seen = new Set();
let match;
JSX_TEXT_PATTERN.lastIndex = 0;
while ((match = JSX_TEXT_PATTERN.exec(source)) !== null) {
if (isInsideStringLiteral(source, match.index)) continue;
const text = match[1].trim();
if (!text) continue;
// Strip out obvious non-strings: single-word identifiers in PascalCase,
// numbers-only, or icon labels that are actually component names.
if (/^[A-Z][A-Za-z]*$/.test(text)) continue;
if (text.length < 3) continue;
// Use the line number of the match.
const lineNumber = source.slice(0, match.index).split("\n").length;
const key = `${lineNumber}:${text}`;
if (seen.has(key)) continue;
seen.add(key);
hits.push({ line: lineNumber, text });
}
return hits;
}
function isInsideStringLiteral(source, index) {
let quote = null;
let escaped = false;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < index; i += 1) {
const char = source[i];
const next = source[i + 1];
if (inLineComment) {
if (char === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
if (char === "*" && next === "/") {
inBlockComment = false;
i += 1;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === quote) quote = null;
continue;
}
if (char === "/" && next === "/") {
inLineComment = true;
i += 1;
continue;
}
if (char === "/" && next === "*") {
inBlockComment = true;
i += 1;
continue;
}
if (char === '"' || char === "'" || char === "`") quote = char;
}
return quote !== null;
}
function extractIntlFormattingUsages(source) {
const hits = [];
const seen = new Set();
let match;
INTL_FORMATTING_PATTERN.lastIndex = 0;
while ((match = INTL_FORMATTING_PATTERN.exec(source)) !== null) {
const expression = match[0].replace(/\($/, "");
const lineNumber = source.slice(0, match.index).split("\n").length;
const key = `${lineNumber}:${expression}`;
if (seen.has(key)) continue;
seen.add(key);
hits.push({ line: lineNumber, expression });
}
return hits;
}
async function auditFile(filePath) {
const source = await readFile(filePath, "utf8");
const hits = extractHardcodedStrings(source);
const intlHits = extractIntlFormattingUsages(source);
if (hits.length === 0 && intlHits.length === 0) return null;
return { filePath: path.relative(repoRoot, filePath), hits, intlHits };
}
async function runAudit() {
const files = [];
for (const root of SCAN_ROOTS) {
const stats = await stat(root).catch(() => null);
if (!stats?.isDirectory()) continue;
files.push(...(await walk(root)));
}
const results = (await Promise.all(files.map(auditFile))).filter(Boolean);
results.sort((a, b) => a.filePath.localeCompare(b.filePath));
const total = results.reduce((acc, file) => acc + file.hits.length, 0);
const hardcodedFiles = results.filter((file) => file.hits.length > 0);
const intlFiles = results
.filter((file) => file.intlHits.length > 0)
.map((file) => ({ filePath: file.filePath, hits: file.intlHits }));
const totalIntlFormattingUsages = intlFiles.reduce((acc, file) => acc + file.hits.length, 0);
return {
scannedFileCount: files.length,
fileHitCount: hardcodedFiles.length,
totalHardcodedStrings: total,
intlFileCount: intlFiles.length,
totalIntlFormattingUsages,
files: hardcodedFiles,
intlFiles,
};
}
function renderMarkdown(report, now) {
const intlFiles = report.intlFiles ?? [];
const intlFileCount = report.intlFileCount ?? intlFiles.length;
const totalIntlFormattingUsages = report.totalIntlFormattingUsages ?? intlFiles.reduce((acc, file) => acc + file.hits.length, 0);
const lines = [];
lines.push(`# i18n Audit — Baseline ${now.toISOString().slice(0, 10)}`);
lines.push("");
lines.push("> Generated by `scripts/i18n-audit.mjs`. Counts hardcoded user-facing");
lines.push("> strings in `apps/web/app/{routes,modules,routeModules,components}/**/*.{ts,tsx}`.");
lines.push("> Baseline only — not yet");
lines.push("> enforced. See `docs/contributing/i18n.md` for the migration playbook.");
lines.push("");
lines.push("## Summary");
lines.push("");
lines.push(`| Metric | Value |`);
lines.push(`| --- | ---: |`);
lines.push(`| Files scanned | ${report.scannedFileCount} |`);
lines.push(`| Files with hardcoded strings | ${report.fileHitCount} |`);
lines.push(`| Total hardcoded JSX text fragments | ${report.totalHardcodedStrings} |`);
lines.push(`| Files with locale-sensitive formatting signals | ${intlFileCount} |`);
lines.push(`| Total locale-sensitive formatting signals | ${totalIntlFormattingUsages} |`);
lines.push("");
if (report.files.length === 0 && intlFiles.length === 0) {
lines.push("No hardcoded strings detected. Audit clean.");
return lines.join("\n");
}
if (report.files.length > 0) {
lines.push("## Top offenders");
lines.push("");
const top = [...report.files].sort((a, b) => b.hits.length - a.hits.length).slice(0, 20);
lines.push(`| File | Hits |`);
lines.push(`| --- | ---: |`);
for (const file of top) {
lines.push(`| \`${file.filePath}\` | ${file.hits.length} |`);
}
lines.push("");
lines.push("## Detail (first 100 hits)");
lines.push("");
lines.push(`| File:Line | Hardcoded text |`);
lines.push(`| --- | --- |`);
let detailCount = 0;
for (const file of report.files) {
for (const hit of file.hits) {
if (detailCount >= 100) break;
const text = hit.text.replace(/\|/g, "\\|").replace(/\n/g, " ");
lines.push(`| \`${file.filePath}:${hit.line}\` | ${text} |`);
detailCount += 1;
}
if (detailCount >= 100) break;
}
lines.push("");
}
if (intlFiles.length > 0) {
lines.push("## Formatting migration signals (first 100 hits)");
lines.push("");
lines.push(`| File:Line | Signal |`);
lines.push(`| --- | --- |`);
let detailCount = 0;
for (const file of intlFiles) {
for (const hit of file.hits) {
if (detailCount >= 100) break;
lines.push(`| \`${file.filePath}:${hit.line}\` | \`${hit.expression}\` |`);
detailCount += 1;
}
if (detailCount >= 100) break;
}
}
return lines.join("\n");
}
function parseArgs(argv) {
const args = { out: null, json: false };
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--json") args.json = true;
else if (arg === "--out") args.out = argv[++i];
else if (arg.startsWith("--out=")) args.out = arg.slice("--out=".length);
}
return args;
}
async function main() {
const args = parseArgs(process.argv);
const report = await runAudit();
if (args.json) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
const now = new Date();
const outPath = args.out
? path.resolve(repoRoot, args.out)
: path.join(repoRoot, "docs/contributing", `i18n-audit-${now.toISOString().slice(0, 10)}.md`);
await mkdir(path.dirname(outPath), { recursive: true });
await writeFile(outPath, `${renderMarkdown(report, now)}\n`, "utf8");
console.log(
`i18n-audit: scanned ${report.scannedFileCount} files, found ${report.totalHardcodedStrings} hardcoded strings across ${report.fileHitCount} files and ${report.totalIntlFormattingUsages} formatting signals across ${report.intlFileCount} files`,
);
console.log(`i18n-audit: report written to ${path.relative(repoRoot, outPath)}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
export { runAudit, renderMarkdown, extractHardcodedStrings, extractIntlFormattingUsages };
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, test } from "vitest";
import { extractHardcodedStrings, extractIntlFormattingUsages, renderMarkdown } from "./i18n-audit.mjs";
describe("i18n-audit renderMarkdown", () => {
test("renders a clean baseline when there are no hits", () => {
const md = renderMarkdown(
{ scannedFileCount: 5, fileHitCount: 0, totalHardcodedStrings: 0, intlFileCount: 0, totalIntlFormattingUsages: 0, files: [], intlFiles: [] },
new Date("2026-05-17T00:00:00Z"),
);
expect(md).toContain("# i18n Audit — Baseline 2026-05-17");
expect(md).toContain("No hardcoded strings detected. Audit clean.");
expect(md).toContain("apps/web/app/{routes,modules,routeModules,components}/**/*.{ts,tsx}");
});
test("includes top-offender + detail tables when hits exist", () => {
const md = renderMarkdown(
{
scannedFileCount: 2,
fileHitCount: 1,
totalHardcodedStrings: 2,
intlFileCount: 0,
totalIntlFormattingUsages: 0,
files: [
{
filePath: "apps/web/app/components/Foo.tsx",
hits: [
{ line: 12, text: "Save changes" },
{ line: 34, text: "Cancel" },
],
},
],
intlFiles: [],
},
new Date("2026-05-17T00:00:00Z"),
);
expect(md).toContain("| `apps/web/app/components/Foo.tsx` | 2 |");
expect(md).toContain("Save changes");
expect(md).toContain("Cancel");
});
test("reports locale-sensitive formatting signals separately from hardcoded JSX text", () => {
const md = renderMarkdown(
{
scannedFileCount: 1,
fileHitCount: 0,
totalHardcodedStrings: 0,
intlFileCount: 1,
totalIntlFormattingUsages: 3,
files: [],
intlFiles: [
{
filePath: "apps/web/app/routes/admin.tsx",
hits: [
{ line: 10, expression: "toLocaleString" },
{ line: 11, expression: "DateUtils.dateYMD" },
{ line: 12, expression: "NumberUtils.intFormat" },
],
},
],
},
new Date("2026-05-17T00:00:00Z"),
);
expect(md).toContain("| Files with locale-sensitive formatting signals | 1 |");
expect(md).toContain("## Formatting migration signals");
expect(md).toContain("| `apps/web/app/routes/admin.tsx:10` | `toLocaleString` |");
expect(md).toContain("| `apps/web/app/routes/admin.tsx:11` | `DateUtils.dateYMD` |");
expect(md).toContain("| `apps/web/app/routes/admin.tsx:12` | `NumberUtils.intFormat` |");
});
});
describe("i18n-audit extractHardcodedStrings", () => {
test("detects visible JSX text", () => {
expect(extractHardcodedStrings("<h1>Visible heading</h1>")).toEqual([{ line: 1, text: "Visible heading" }]);
});
test("ignores html fragments inside string literals", () => {
expect(
extractHardcodedStrings(
[
'const html = "<p>Watch the demo video:</p>";',
"const body = '<strong>It works!</strong>';",
"const template = `<section>Configure account</section>`;",
].join("\n"),
),
).toEqual([]);
});
});
describe("i18n-audit extractIntlFormattingUsages", () => {
test("finds toLocale, DateUtils, and NumberUtils usages", () => {
expect(
extractIntlFormattingUsages(
[
"const amount = value.toLocaleString();",
"const date = DateUtils.dateYMD(createdAt);",
"const count = NumberUtils.intFormat(total);",
].join("\n"),
),
).toEqual([
{ line: 1, expression: "toLocaleString" },
{ line: 2, expression: "DateUtils.dateYMD" },
{ line: 3, expression: "NumberUtils.intFormat" },
]);
});
});
+469
View File
@@ -0,0 +1,469 @@
import { mkdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { checkI18nParity, collectLeafKeys, formatI18nParityReport } from "./i18n-check.ts";
async function makeFixture() {
const root = await mkdir(path.join(os.tmpdir(), `i18n-parity-${crypto.randomUUID()}`), { recursive: true });
if (!root) throw new Error("Failed to create i18n parity fixture");
return { root };
}
async function writeTranslations(root: string, locale: string, translations: unknown) {
await writeNamespace(root, locale, "translations", translations);
}
async function writeNamespace(root: string, locale: string, namespace: string, translations: unknown) {
const localeRoot = path.join(root, "apps", "web", "public", "locales", locale);
await mkdir(localeRoot, { recursive: true });
await writeFile(path.join(localeRoot, `${namespace}.json`), `${JSON.stringify(translations, null, 2)}\n`, "utf8");
}
async function writeSource(root: string, relativePath: string, source: string) {
const filePath = path.join(root, relativePath);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, source, "utf8");
}
function checkFixtureParity(
root: string,
options: Partial<Parameters<typeof checkI18nParity>[0]> = {},
): ReturnType<typeof checkI18nParity> {
return checkI18nParity({ cwd: root, locales: ["de", "en"], referenceLocale: "de", namespaces: ["translations"], ...options });
}
describe("i18n parity guard", () => {
test("collects nested translation leaf keys deterministically", () => {
expect(
collectLeafKeys({
dashboard: { title: "Dashboard", stats: { empty: "No data" } },
common: { cancel: "Cancel" },
}),
).toEqual(["common.cancel", "dashboard.stats.empty", "dashboard.title"]);
});
test("passes when supported locale files share identical nested leaf keys", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
common: { cancel: "Abbrechen", save: "Speichern" },
dashboard: { stats: { empty: "Keine Daten" }, title: "Dashboard" },
});
await writeTranslations(root, "en", {
common: { cancel: "Cancel", save: "Save" },
dashboard: { stats: { empty: "No data" }, title: "Dashboard" },
});
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(true);
expect(result.keyCount).toBe(4);
expect(report).toBe("PASS i18n-parity: 2 locales (de, en) share 4 translation keys across 1 namespaces");
});
test("fails with a clear report when a configured locale file is missing", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toEqual([
{
type: "missing-file",
locale: "en",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
},
]);
expect(report).toContain("FAIL i18n-parity");
expect(report).toContain(path.join("apps", "web", "public", "locales", "en", "translations.json"));
});
test("fails when the reference locale contains zero translation keys", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {});
await writeTranslations(root, "en", {});
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.keyCount).toBe(0);
expect(result.issues).toEqual([
{
type: "empty-reference",
locale: "de",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"),
},
]);
expect(report).toContain("empty reference namespace [de/translations]");
expect(report).toContain("expected at least one translation key");
});
test("fails with missing and extra key diagnostics when English diverges from German", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
common: { cancel: "Abbrechen", save: "Speichern" },
dashboard: { title: "Dashboard" },
});
await writeTranslations(root, "en", {
common: { cancel: "Cancel", search: "Search" },
dashboard: { title: "Dashboard" },
});
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toEqual([
{
type: "key-divergence",
locale: "en",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
referenceLocale: "de",
missingKeys: ["common.save"],
extraKeys: ["common.search"],
},
]);
expect(report).toContain("key divergence [en/translations] vs [de/translations]");
expect(report).toContain("missing keys:\n - common.save");
expect(report).toContain("extra keys:\n - common.search");
});
test("fails when locale values are blank even when keys are present", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
common: { cancel: "Abbrechen", save: " " },
dashboard: { title: "Dashboard" },
});
await writeTranslations(root, "en", {
common: { cancel: "Cancel", save: "Save" },
dashboard: { title: "" },
});
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "empty-translation",
locale: "de",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "de", "translations.json"),
keys: ["common.save"],
});
expect(result.issues).toContainEqual({
type: "empty-translation",
locale: "en",
namespace: "translations",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "translations.json"),
keys: ["dashboard.title"],
});
expect(report).toContain("empty translation values [de/translations]");
expect(report).toContain("keys:\n - common.save");
});
test("fails when an unconfigured locale directory is present", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeTranslations(root, "fr", { common: { save: "Enregistrer" } });
const result = checkFixtureParity(root);
expect(result.issues).toContainEqual({
type: "unexpected-locale-dir",
locale: "fr",
directoryPath: path.join(root, "apps", "web", "public", "locales", "fr"),
});
});
test("passes static source keys from packages when the reference locale contains them", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
package: { example: { title: "Paket" } },
models: { role: { viewPermissions_one: "Eine Berechtigung", viewPermissions_other: "{{count}} Berechtigungen" } },
});
await writeTranslations(root, "en", {
package: { example: { title: "Package" } },
models: { role: { viewPermissions_one: "One permission", viewPermissions_other: "{{count}} permissions" } },
});
await writeSource(
root,
"packages/example/src/component.tsx",
'export function Component({ t, count }) { return <>{t("package.example.title")}{t("models.role.viewPermissions", { count })}</>; }\n',
);
const result = checkFixtureParity(root);
expect(result.ok).toBe(true);
});
test("accepts static source keys from non-default locale namespaces", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeNamespace(root, "de", "errors", { "404": { title: "Nicht gefunden" }, notFound: "Nicht gefunden" });
await writeNamespace(root, "en", "errors", { "404": { title: "Not found" }, notFound: "Not found" });
await writeNamespace(root, "de", "mail", { welcome: { subject: "Willkommen" } });
await writeNamespace(root, "en", "mail", { welcome: { subject: "Welcome" } });
await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader({ t }) { return t("404.title") + t("notFound"); }\n');
await writeSource(root, "packages/mail/src/template.ts", 'export function mail(t) { return t("welcome.subject"); }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "errors", "mail"] });
expect(result.ok).toBe(true);
});
test("extracts static translation keys from JSX attributes", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", {
common: { banner: { title: "Titel" }, description: "Beschreibung" },
});
await writeTranslations(root, "en", {
common: { banner: { title: "Title" }, description: "Description" },
});
await writeSource(
root,
"apps/web/app/components/Banner.tsx",
'export function Banner() { return <Trans i18nKey="common.banner.title" descriptionKey="common.description" />; }\n',
);
const result = checkFixtureParity(root);
expect(result.ok).toBe(true);
});
test("fails when static package source keys are missing from the reference locale", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeSource(root, "packages/example/src/component.tsx", 'export function Component({ t }) { return t("package.example.title"); }\n');
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "package.example.title",
namespace: null,
fallback: "no-fallback",
files: ["packages/example/src/component.tsx"],
});
expect(report).toContain("missing source translation key (no-fallback): package.example.title");
});
test("checks every configured namespace file and key parity per locale", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen", save: "Speichern" } });
await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel", search: "Search" } });
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.keyCount).toBe(3);
expect(result.issues).toContainEqual({
type: "key-divergence",
locale: "en",
namespace: "common",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"),
referenceLocale: "de",
missingKeys: ["actions.save"],
extraKeys: ["actions.search"],
});
expect(report).toContain("key divergence [en/common] vs [de/common]");
});
test("reports missing configured namespace files", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } });
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
expect(result.issues).toContainEqual({
type: "missing-file",
locale: "en",
namespace: "common",
filePath: path.join(root, "apps", "web", "public", "locales", "en", "common.json"),
});
});
test("uses explicit source namespaces instead of accepting keys from another namespace", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { actions: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { actions: { save: "Save" } });
await writeNamespace(root, "de", "common", { actions: { cancel: "Abbrechen" } });
await writeNamespace(root, "en", "common", { actions: { cancel: "Cancel" } });
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form({ t }) { return t("actions.save", { ns: "common" }); }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "actions.save",
namespace: "common",
fallback: "no-fallback",
files: ["apps/web/app/components/Form.tsx"],
});
expect(report).toContain("missing source translation key (no-fallback): common:actions.save");
});
test("flags namespace keys that use a dot instead of the namespace colon separator", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
// settings.json nutzt flache, gepunktete Keys (erreichbar nur via "settings:tenant.theme.title").
await writeNamespace(root, "de", "settings", { "tenant.theme.title": "Darstellung" });
await writeNamespace(root, "en", "settings", { "tenant.theme.title": "Appearance" });
// Bug-Muster: Punkt statt Doppelpunkt -> adressiert den defaultNS, nicht den settings-Namespace,
// und erscheint zur Laufzeit als nackter Key. Korrekt waere t("settings:tenant.theme.title").
await writeSource(root, "apps/web/app/components/Theme.tsx", 'export function Theme({ t }) { return t("settings.tenant.theme.title"); }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "settings"] });
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "settings.tenant.theme.title",
namespace: null,
fallback: "no-fallback",
files: ["apps/web/app/components/Theme.tsx"],
});
expect(report).toContain("missing source translation key (no-fallback): settings.tenant.theme.title");
});
test("classifies missing source keys with fallback separately from missing source keys without fallback", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeSource(
root,
"apps/web/app/routes/example.tsx",
'export function Route({ t }) { return t("missing.withFallback", { defaultValue: "Fallback" }) + t("missing.withoutFallback"); }\n',
);
const result = checkFixtureParity(root);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "missing.withFallback",
namespace: null,
fallback: "fallback",
files: ["apps/web/app/routes/example.tsx"],
});
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "missing.withoutFallback",
namespace: null,
fallback: "no-fallback",
files: ["apps/web/app/routes/example.tsx"],
});
});
test("does not fail the gate when missing source keys have fallback text", async () => {
const { root } = await makeFixture();
await writeTranslations(root, "de", { common: { save: "Speichern" } });
await writeTranslations(root, "en", { common: { save: "Save" } });
await writeSource(root, "apps/web/app/routes/example.tsx", 'export function Route({ t }) { return t("missing.withFallback", "Fallback"); }\n');
const result = checkFixtureParity(root);
const report = formatI18nParityReport(result);
expect(result.ok).toBe(true);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "missing.withFallback",
namespace: null,
fallback: "fallback",
files: ["apps/web/app/routes/example.tsx"],
});
expect(report).toContain("PASS i18n-parity");
expect(report).toContain("missing source translation key (fallback): missing.withFallback");
});
test("infers source namespace from namespace-specific translation loaders", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { notFound: "Nicht gefunden" });
await writeNamespace(root, "en", "translations", { notFound: "Not found" });
await writeNamespace(root, "de", "errors", { notFound: "Nicht gefunden" });
await writeNamespace(root, "en", "errors", { notFound: "Not found" });
await writeSource(root, "apps/web/app/routes/404.tsx", 'export async function loader(request) { const { t } = await getTranslations(request, "errors"); return t("notFound"); }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "errors"] });
expect(result.ok).toBe(true);
});
test("applies static key prefixes from translation hooks and fixed translators", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" }, admin: { title: "Admin" } });
await writeNamespace(root, "en", "common", { actions: { save: "Save" }, admin: { title: "Admin" } });
await writeSource(
root,
"apps/web/app/components/Form.tsx",
'export function Form() { const { t } = useTranslation("common", { keyPrefix: "actions" }); return t("save"); }\n',
);
await writeSource(
root,
"apps/web/app/routes/Admin.tsx",
'export async function loader(i18n) { const t = await i18n.getFixedT("de", "common", "admin"); return t("title"); }\n',
);
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
expect(result.ok).toBe(true);
});
test("uses nearby JSX namespaces for Trans i18n keys instead of accepting matching default keys", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
await writeNamespace(root, "de", "common", { shared: { cancel: "Abbrechen" } });
await writeNamespace(root, "en", "common", { shared: { cancel: "Cancel" } });
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { return <Trans ns="common" i18nKey="shared.save" />; }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
const report = formatI18nParityReport(result);
expect(result.ok).toBe(false);
expect(result.issues).toContainEqual({
type: "missing-source-key",
key: "shared.save",
namespace: "common",
fallback: "no-fallback",
files: ["apps/web/app/components/Form.tsx"],
});
expect(report).toContain("missing source translation key (no-fallback): common:shared.save");
});
test("extracts selector-style translation keys", async () => {
const { root } = await makeFixture();
await writeNamespace(root, "de", "translations", { shared: { save: "Speichern" } });
await writeNamespace(root, "en", "translations", { shared: { save: "Save" } });
await writeNamespace(root, "de", "common", { actions: { save: "Speichern" } });
await writeNamespace(root, "en", "common", { actions: { save: "Save" } });
await writeSource(root, "apps/web/app/components/Form.tsx", 'export function Form() { const { t } = useTranslation("common"); return t(($) => $.actions.save); }\n');
const result = checkFixtureParity(root, { namespaces: ["translations", "common"] });
expect(result.ok).toBe(true);
});
});
+502
View File
@@ -0,0 +1,502 @@
#!/usr/bin/env tsx
/* eslint-disable no-console */
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { defaultLocale, i18nNamespaces, supportedLocales } from "../packages/i18n/src/index.ts";
export type JsonObject = Record<string, unknown>;
export type MissingSourceKeyFallback = "fallback" | "no-fallback";
export type LocaleParityIssue =
| {
type: "missing-file";
locale: string;
namespace: string;
filePath: string;
}
| {
type: "invalid-json";
locale: string;
namespace: string;
filePath: string;
message: string;
}
| {
type: "invalid-root";
locale: string;
namespace: string;
filePath: string;
}
| {
type: "key-divergence";
locale: string;
namespace: string;
filePath: string;
referenceLocale: string;
missingKeys: string[];
extraKeys: string[];
}
| {
type: "unexpected-locale-dir";
locale: string;
directoryPath: string;
}
| {
type: "missing-source-key";
key: string;
namespace: string | null;
fallback: MissingSourceKeyFallback;
files: string[];
}
| {
type: "empty-reference";
locale: string;
namespace: string;
filePath?: string;
}
| {
type: "empty-translation";
locale: string;
namespace: string;
filePath: string;
keys: string[];
};
export type LocaleKeySummary = {
locale: string;
namespace: string;
filePath: string;
keyCount: number;
};
export type I18nParityResult = {
ok: boolean;
referenceLocale: string;
namespaces: string[];
keyCount: number;
locales: LocaleKeySummary[];
issues: LocaleParityIssue[];
};
export type I18nParityOptions = {
cwd?: string;
localesRoot?: string;
locales?: readonly string[];
referenceLocale?: string;
namespaces?: readonly string[];
sourceRoots?: readonly string[];
};
type SourceTranslationKey = {
key: string;
namespace: string | null;
fallback: MissingSourceKeyFallback;
};
const DEFAULT_SOURCE_ROOTS = ["apps/web/app", "packages"] as const;
const SOURCE_FILE_PATTERN = /\.(ts|tsx)$/;
const TEST_FILE_PATTERN = /\.(test|spec)\.(ts|tsx)$/;
const IGNORED_SOURCE_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".cache", "generated"]);
const DEFAULT_NAMESPACE = "translations";
function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function collectLeafKeys(value: unknown, prefix = ""): string[] {
if (!isJsonObject(value)) return prefix ? [prefix] : [];
const keys = Object.keys(value).sort((left, right) => left.localeCompare(right));
if (keys.length === 0) return prefix ? [prefix] : [];
return keys.flatMap((key) => {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
const child = value[key];
if (isJsonObject(child)) return collectLeafKeys(child, nextPrefix);
return [nextPrefix];
});
}
export function checkI18nParity(options: I18nParityOptions = {}): I18nParityResult {
const cwd = options.cwd ?? process.cwd();
const localesRoot = options.localesRoot ?? resolveLocalesRoot(cwd);
const locales = [...(options.locales ?? supportedLocales)];
const namespaces = [...(options.namespaces ?? i18nNamespaces)];
const configuredReferenceLocale = options.referenceLocale ?? defaultLocale;
const referenceLocale = locales.includes(configuredReferenceLocale) ? configuredReferenceLocale : locales[0] ?? configuredReferenceLocale;
const configuredLocales = new Set(locales);
const issues: LocaleParityIssue[] = [];
const keySetsByLocale = new Map<string, Map<string, Set<string>>>();
const summaries: LocaleKeySummary[] = [];
if (existsSync(localesRoot)) {
for (const entry of readdirSync(localesRoot, { withFileTypes: true })) {
if (entry.isDirectory() && !configuredLocales.has(entry.name)) {
issues.push({ type: "unexpected-locale-dir", locale: entry.name, directoryPath: path.join(localesRoot, entry.name) });
}
}
}
for (const locale of locales) {
const localeKeySets = new Map<string, Set<string>>();
keySetsByLocale.set(locale, localeKeySets);
for (const namespace of namespaces) {
const filePath = path.join(localesRoot, locale, `${namespace}.json`);
if (!existsSync(filePath)) {
issues.push({ type: "missing-file", locale, namespace, filePath });
continue;
}
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(filePath, "utf8"));
} catch (error) {
issues.push({
type: "invalid-json",
locale,
namespace,
filePath,
message: error instanceof Error ? error.message : "Unknown JSON parse error",
});
continue;
}
if (!isJsonObject(parsed)) {
issues.push({ type: "invalid-root", locale, namespace, filePath });
continue;
}
const leafKeys = collectLeafKeys(parsed);
const emptyTranslationKeys = collectEmptyTranslationKeys(parsed);
if (emptyTranslationKeys.length > 0) {
issues.push({ type: "empty-translation", locale, namespace, filePath, keys: emptyTranslationKeys });
}
localeKeySets.set(namespace, new Set(leafKeys));
summaries.push({ locale, namespace, filePath, keyCount: leafKeys.length });
}
}
const referenceNamespaces = keySetsByLocale.get(referenceLocale);
if (referenceNamespaces) {
for (const namespace of namespaces) {
const referenceKeys = referenceNamespaces.get(namespace);
if (!referenceKeys) continue;
const sortedReferenceKeys = [...referenceKeys].sort((left, right) => left.localeCompare(right));
const referenceSummary = summaries.find((summary) => summary.locale === referenceLocale && summary.namespace === namespace);
if (sortedReferenceKeys.length === 0) {
issues.push({ type: "empty-reference", locale: referenceLocale, namespace, filePath: referenceSummary?.filePath });
}
for (const summary of summaries) {
if (summary.locale === referenceLocale || summary.namespace !== namespace) continue;
const localeKeys = keySetsByLocale.get(summary.locale)?.get(namespace) ?? new Set<string>();
const missingKeys = sortedReferenceKeys.filter((key) => !localeKeys.has(key));
const extraKeys = [...localeKeys].filter((key) => !referenceKeys.has(key)).sort((left, right) => left.localeCompare(right));
if (missingKeys.length > 0 || extraKeys.length > 0) {
issues.push({
type: "key-divergence",
locale: summary.locale,
namespace,
filePath: summary.filePath,
referenceLocale,
missingKeys,
extraKeys,
});
}
}
}
for (const [sourceKey, files] of collectStaticSourceTranslationKeys(cwd, options.sourceRoots ?? DEFAULT_SOURCE_ROOTS, namespaces)) {
if (!hasSourceTranslationKey(referenceNamespaces, sourceKey, namespaces)) {
issues.push({ type: "missing-source-key", ...sourceKey, files });
}
}
}
return {
ok: issues.every((issue) => !isBlockingIssue(issue)),
referenceLocale,
namespaces,
keyCount: [...(referenceNamespaces?.values() ?? [])].reduce((count, keys) => count + keys.size, 0),
locales: summaries.sort((left, right) => `${left.locale}:${left.namespace}`.localeCompare(`${right.locale}:${right.namespace}`)),
issues,
};
}
function hasSourceTranslationKey(referenceNamespaces: Map<string, Set<string>>, sourceKey: SourceTranslationKey, namespaces: readonly string[]) {
if (sourceKey.namespace) {
return hasTranslationKey(referenceNamespaces.get(sourceKey.namespace) ?? new Set<string>(), sourceKey.key);
}
// Laufzeit-Semantik (i18next): der Namespace wird mit ":" getrennt (nsSeparator), NICHT mit ".".
// Ein Key wie `t("settings.tenant.theme.title")` adressiert daher NICHT den `settings`-Namespace,
// sondern den effektiven (Default-)Namespace mit dem vollständigen, gepunkteten Key. Wir prüfen den
// Key deshalb nur als vollständigen Key in den (effektiven) Namespaces. KEIN "erstes Punkt-Segment
// als Namespace"-Fallback: der würde einen Punkt-statt-Doppelpunkt-Fehler (im UI ein nackter Key)
// stillschweigend als gültig durchwinken -- genau die Lücke, die den Theme-Settings-Bug verdeckte.
return namespaces.some((namespace) => hasTranslationKey(referenceNamespaces.get(namespace) ?? new Set<string>(), sourceKey.key));
}
function hasTranslationKey(keys: Set<string>, key: string) {
return keys.has(key) || keys.has(`${key}_one`) || keys.has(`${key}_other`);
}
function collectEmptyTranslationKeys(value: unknown, prefix = ""): string[] {
if (!isJsonObject(value)) {
return typeof value === "string" && value.trim().length === 0 && prefix ? [prefix] : [];
}
return Object.keys(value)
.sort((left, right) => left.localeCompare(right))
.flatMap((key) => collectEmptyTranslationKeys(value[key], prefix ? `${prefix}.${key}` : key));
}
function collectStaticSourceTranslationKeys(cwd: string, sourceRoots: readonly string[], namespaces: readonly string[]) {
const keys = new Map<string, { sourceKey: SourceTranslationKey; files: string[] }>();
for (const sourceRoot of sourceRoots) {
const absoluteRoot = path.join(cwd, sourceRoot);
if (!existsSync(absoluteRoot)) continue;
for (const filePath of walkSourceFiles(absoluteRoot)) {
const relativePath = slash(path.relative(cwd, filePath));
const source = readFileSync(filePath, "utf8");
for (const sourceKey of extractStaticTranslationKeys(source, namespaces)) {
const id = `${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}`;
const entry = keys.get(id) ?? { sourceKey, files: [] };
entry.files.push(relativePath);
keys.set(id, entry);
}
}
}
return [...keys.values()]
.map(({ sourceKey, files }) => [sourceKey, [...new Set(files)].sort((left, right) => left.localeCompare(right))] as const)
.sort(([left], [right]) => `${left.namespace ?? "*"}:${left.key}`.localeCompare(`${right.namespace ?? "*"}:${right.key}`));
}
function walkSourceFiles(directory: string): string[] {
return readdirSync(directory).flatMap((entry) => {
const filePath = path.join(directory, entry);
const stats = statSync(filePath);
if (stats.isDirectory()) {
if (IGNORED_SOURCE_DIRS.has(entry)) return [];
return walkSourceFiles(filePath);
}
if (!SOURCE_FILE_PATTERN.test(entry) || TEST_FILE_PATTERN.test(entry) || entry.endsWith(".generated.ts") || entry.endsWith(".generated.tsx")) {
return [];
}
return [filePath];
});
}
function extractStaticTranslationKeys(source: string, namespaces: readonly string[]) {
const keys = new Map<string, SourceTranslationKey>();
const namespaceSet = new Set(namespaces);
const inferredNamespace = inferSingleNamespace(source, namespaceSet);
const inferredKeyPrefix = inferSingleKeyPrefix(source);
const localFallbackKeys = collectLocalDictionaryKeys(source);
const addKey = (rawKey: string, fallback: MissingSourceKeyFallback, namespace: string | null = inferredNamespace, keyPrefix: string | null = inferredKeyPrefix) => {
if (rawKey.endsWith(".")) return;
const parsed = parseTranslationKey(rawKey, namespace, namespaceSet);
const key = applyKeyPrefix(parsed.key, rawKey, keyPrefix);
const sourceKey = { key, namespace: parsed.namespace, fallback };
keys.set(`${sourceKey.namespace ?? "*"}:${sourceKey.key}:${sourceKey.fallback}`, sourceKey);
};
for (const match of source.matchAll(/\bt\(\s*["']([A-Za-z0-9_.:-]+)["']([^)]*)\)/g)) {
const rawKey = match[1];
const tail = match[2] ?? "";
const namespace = extractOptionNamespace(tail, namespaceSet) ?? inferredNamespace;
const keyPrefix = extractOptionKeyPrefix(tail) ?? inferredKeyPrefix;
addKey(rawKey, hasDefaultValueFallback(tail) || localFallbackKeys.has(rawKey) ? "fallback" : "no-fallback", namespace, keyPrefix);
}
for (const match of source.matchAll(/\bt\(\s*\(?\s*\$[A-Za-z0-9_]*\s*\)?\s*=>\s*\$[A-Za-z0-9_]*((?:\.[A-Za-z_$][\w$]*)+)([^)]*)\)/g)) {
const rawKey = match[1].slice(1);
const tail = match[2] ?? "";
const namespace = extractOptionNamespace(tail, namespaceSet) ?? inferredNamespace;
addKey(rawKey, hasDefaultValueFallback(tail) ? "fallback" : "no-fallback", namespace, null);
}
for (const match of source.matchAll(/\bi18nKey\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
const index = match.index ?? 0;
addKey(match[1], hasNearbyLiteralFallback(source, index) ? "fallback" : "no-fallback", extractNearbyNamespace(source, index, namespaceSet), extractNearbyKeyPrefix(source, index));
}
for (const match of source.matchAll(/\b(?:labelKey|titleKey|descriptionKey|messageKey)\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
addKey(match[1], hasNearbyLiteralFallback(source, match.index ?? 0) ? "fallback" : "no-fallback", null);
}
for (const match of source.matchAll(/\bi18nKey\s*=\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
const index = match.index ?? 0;
addKey(match[1], "no-fallback", extractNearbyNamespace(source, index, namespaceSet), extractNearbyKeyPrefix(source, index));
}
for (const match of source.matchAll(/\b(?:labelKey|titleKey|descriptionKey|messageKey)\s*=\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
addKey(match[1], "no-fallback", null);
}
return [...keys.values()].sort((left, right) => `${left.namespace ?? "*"}:${left.key}`.localeCompare(`${right.namespace ?? "*"}:${right.key}`));
}
function parseTranslationKey(rawKey: string, namespace: string | null, namespaces: Set<string>) {
const separatorIndex = rawKey.indexOf(":");
if (separatorIndex > 0) {
const candidateNamespace = rawKey.slice(0, separatorIndex);
if (namespaces.has(candidateNamespace)) {
return { namespace: candidateNamespace, key: rawKey.slice(separatorIndex + 1) };
}
}
return { namespace, key: rawKey };
}
function inferSingleNamespace(source: string, namespaces: Set<string>) {
const found = new Set<string>();
for (const match of source.matchAll(/\b(?:useTranslation|useTranslate|getTranslations)\s*\([^)]*["']([A-Za-z0-9_-]+)["']/g)) {
if (namespaces.has(match[1]) && match[1] !== DEFAULT_NAMESPACE) found.add(match[1]);
}
for (const match of source.matchAll(/\bgetFixedT\s*\([^)]*,\s*["']([A-Za-z0-9_-]+)["']/g)) {
if (namespaces.has(match[1]) && match[1] !== DEFAULT_NAMESPACE) found.add(match[1]);
}
return found.size === 1 ? [...found][0] : null;
}
function extractOptionNamespace(source: string, namespaces: Set<string>) {
const match = source.match(/\bns\s*:\s*["']([A-Za-z0-9_-]+)["']/);
return match && namespaces.has(match[1]) ? match[1] : null;
}
function inferSingleKeyPrefix(source: string) {
const found = new Set<string>();
for (const match of source.matchAll(/\b(?:useTranslation|useTranslate|getTranslations)\s*\([^)]*\bkeyPrefix\s*:\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
found.add(match[1]);
}
for (const match of source.matchAll(/\bgetFixedT\s*\([^)]*,\s*["'][A-Za-z0-9_-]+["']\s*,\s*["']([A-Za-z0-9_.:-]+)["']/g)) {
found.add(match[1]);
}
return found.size === 1 ? [...found][0] : null;
}
function extractOptionKeyPrefix(source: string) {
return source.match(/\bkeyPrefix\s*:\s*["']([A-Za-z0-9_.:-]+)["']/)?.[1] ?? null;
}
function applyKeyPrefix(key: string, rawKey: string, keyPrefix: string | null) {
if (!keyPrefix || rawKey.includes(":") || key.startsWith(`${keyPrefix}.`)) return key;
return `${keyPrefix}.${key}`;
}
function extractNearbyNamespace(source: string, index: number, namespaces: Set<string>) {
const window = extractNearbyKeyContainer(source, index);
if (!window) return null;
const jsxMatch = window.match(/\bns\s*=\s*["']([A-Za-z0-9_-]+)["']/);
if (jsxMatch && namespaces.has(jsxMatch[1])) return jsxMatch[1];
return extractOptionNamespace(window, namespaces);
}
function extractNearbyKeyPrefix(source: string, index: number) {
const window = extractNearbyKeyContainer(source, index);
if (!window) return null;
return window.match(/\bkeyPrefix\s*=\s*["']([A-Za-z0-9_.:-]+)["']/)?.[1] ?? extractOptionKeyPrefix(window);
}
function hasDefaultValueFallback(source: string) {
return /^\s*,\s*["']/.test(source) || /\bdefaultValue\s*:/.test(source);
}
function collectLocalDictionaryKeys(source: string) {
const keys = new Set<string>();
const dictionaryMatch = source.match(/\b(?:englishMailDictionary|fallbackDictionary)\b[\s\S]*?=\s*\{([\s\S]*?)\n\};/);
if (!dictionaryMatch) return keys;
for (const match of dictionaryMatch[1].matchAll(/["']([A-Za-z0-9_.:-]+)["']\s*:/g)) {
keys.add(match[1]);
}
return keys;
}
function hasNearbyLiteralFallback(source: string, index: number) {
const objectSource = extractNearbyKeyContainer(source, index);
if (!objectSource) return false;
return /\b(?:labelText|title|description|message|fallback|defaultValue)\s*:/.test(objectSource);
}
function extractNearbyKeyContainer(source: string, index: number) {
const objectStart = source.lastIndexOf("{", index);
const objectEnd = source.indexOf("}", index);
if (objectStart >= 0 && objectEnd >= 0 && objectEnd - objectStart <= 800) return source.slice(objectStart, objectEnd);
const tagStart = source.lastIndexOf("<", index);
const tagEnd = source.indexOf(">", index);
if (tagStart >= 0 && tagEnd >= 0 && tagEnd - tagStart <= 800) return source.slice(tagStart, tagEnd);
return null;
}
function slash(filePath: string) {
return filePath.split(path.sep).join("/");
}
function resolveLocalesRoot(cwd: string) {
return path.join(cwd, "apps", "web", "public", "locales");
}
export function formatI18nParityReport(result: I18nParityResult): string {
if (result.ok) {
const locales = [...new Set(result.locales.map((summary) => summary.locale))].join(", ");
const summary = `PASS i18n-parity: ${new Set(result.locales.map((summary) => summary.locale)).size} locales (${locales}) share ${result.keyCount} translation keys across ${result.namespaces.length} namespaces`;
const warnings = result.issues.filter((issue) => !isBlockingIssue(issue));
return warnings.length === 0 ? summary : [summary, ...warnings.map(formatIssue)].join("\n");
}
return ["FAIL i18n-parity: locale namespace files are not in parity", ...result.issues.map(formatIssue)].join("\n");
}
function isBlockingIssue(issue: LocaleParityIssue) {
return issue.type !== "missing-source-key" || issue.fallback === "no-fallback";
}
function formatIssue(issue: LocaleParityIssue): string {
if (issue.type === "missing-file") return `- missing locale namespace file [${issue.locale}/${issue.namespace}]: ${issue.filePath}`;
if (issue.type === "invalid-json") return `- invalid JSON [${issue.locale}/${issue.namespace}]: ${issue.filePath} (${issue.message})`;
if (issue.type === "invalid-root") return `- invalid root [${issue.locale}/${issue.namespace}]: ${issue.filePath} (expected a JSON object)`;
if (issue.type === "empty-reference") {
return `- empty reference namespace [${issue.locale}/${issue.namespace}]: ${issue.filePath ?? "missing reference file"} (expected at least one translation key)`;
}
if (issue.type === "empty-translation") {
return [`- empty translation values [${issue.locale}/${issue.namespace}]: ${issue.filePath}`, formatKeyList("keys", issue.keys)].join("\n");
}
if (issue.type === "unexpected-locale-dir") return `- unexpected locale directory [${issue.locale}]: ${issue.directoryPath}`;
if (issue.type === "missing-source-key") {
const namespace = issue.namespace ? `${issue.namespace}:` : "";
return [`- missing source translation key (${issue.fallback}): ${namespace}${issue.key}`, formatKeyList("files", issue.files)].join("\n");
}
return [
`- key divergence [${issue.locale}/${issue.namespace}] vs [${issue.referenceLocale}/${issue.namespace}]: ${issue.filePath}`,
formatKeyList("missing keys", issue.missingKeys),
formatKeyList("extra keys", issue.extraKeys),
].join("\n");
}
function formatKeyList(label: string, keys: string[]): string {
if (keys.length === 0) return ` ${label}: none`;
return [` ${label}:`, ...keys.map((key) => ` - ${key}`)].join("\n");
}
if (import.meta.url === `file://${process.argv[1]}`) {
const result = checkI18nParity();
const report = formatI18nParityReport(result);
if (result.ok) {
console.log(report);
} else {
console.error(report);
process.exitCode = 1;
}
}
+35
View File
@@ -0,0 +1,35 @@
// Single source of truth for infra strings asserted by verify-gitea-*-workflow.mjs.
// Forks override the defaults by exporting TOOLCTRL_* env vars in their CI environment.
// Default values match the canonical ToolCTRL Gitea deployment so the boilerplate's own
// CI passes without any env setup.
const envOr = (key, fallback) => {
const value = process.env[key];
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
return trimmed ? trimmed : fallback;
};
export const CI_RUNNER_LABEL = envOr("TOOLCTRL_CI_RUNNER", "toolctrl-ci");
export const IMAGE_PREFIX = envOr("TOOLCTRL_IMAGE_PREFIX", "toolctrl");
export const GIT_HOST = envOr("TOOLCTRL_GIT_HOST", "git.tool-ctrl.de");
export const REGISTRY_NAMESPACE = envOr("TOOLCTRL_REGISTRY_NAMESPACE", "toolctrl-labs");
export const DB_USER = envOr("TOOLCTRL_DB_USER", "toolctrl");
export const DB_NAME = envOr("TOOLCTRL_DB_NAME", "toolctrl");
export const PREFLIGHT_DATABASE_URL = `postgresql://${DB_USER}:${DB_USER}@localhost:5432/${DB_NAME}?schema=public`;
export function dockerImageTag(target, tag = "smoke") {
return `${IMAGE_PREFIX}-${target}:${tag}`;
}
export function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export const HOST_OVERRIDE_WRITE_PATTERN = new RegExp(
`printf\\s+'%s ${escapeRegex(GIT_HOST)}\\\\n'\\s+"\\$TOOLCTRL_GITEA_HOST_IP"\\s+>>\\s+/etc/hosts`,
"g"
);
export const HOST_OVERRIDE_VALIDATION_PATTERN =
/node\s+-e\s+'process\.exit\(require\("node:net"\)\.isIP\(process\.env\.TOOLCTRL_GITEA_HOST_IP \|\| ""\) \? 0 : 1\)'/g;
@@ -0,0 +1,4 @@
{
"__doc__": "Reviewed-exception allowlist for verify-licenses.mjs. Keys are `name@version` strings; values are short justifications. Entries here bypass the SPDX classifier — only add a dep after manual legal review.",
"__example__": "moment@2.29.4 -- legacy MIT shim, vendored upstream license file confirmed"
}
@@ -0,0 +1,52 @@
/**
* Puppeteer auth script used by Lighthouse-CI to log in before collecting
* audits against authenticated/admin routes. The script is loaded via
* `.lighthouserc.json`'s `puppeteerScript` field; LHCI invokes it with the
* browser instance and the URL it is about to audit.
*
* Auth strategy:
* - For public/marketing routes (no `/app` or `/admin` prefix) we do not
* attempt login; the script is a no-op so audits still run.
* - For `/app/*` and `/admin/*` routes the script POSTs the boilerplate's
* session cookie (provisioned by the staging Lighthouse runner via
* `LIGHTHOUSE_SESSION_COOKIE`) directly into the browser context. This
* mirrors the spec's "auth-via-cookie" strategy and avoids brittle UI
* login flows.
*
* Required env vars (set by the CI workflow):
* - `LIGHTHOUSE_SESSION_COOKIE` — value of the staging session cookie for a
* Lighthouse test user. Minted by the CI workflow before LHCI starts.
* - `LIGHTHOUSE_SESSION_COOKIE_NAME` (optional) — session cookie name.
* Defaults to `RJ_session`.
* - `LIGHTHOUSE_COOKIE_DOMAIN` (optional) — overrides the cookie domain.
* Defaults to the host of the audited URL.
*
* Refs: docs/improvement-plan/10-compliance-polish/lighthouse.md (Step (g)).
*/
/** @type {(browser: import('puppeteer').Browser, ctx: { url: string, options: object }) => Promise<void>} */
module.exports = async (browser, context) => {
const sessionCookie = process.env.LIGHTHOUSE_SESSION_COOKIE;
const sessionCookieName = process.env.LIGHTHOUSE_SESSION_COOKIE_NAME || "RJ_session";
const targetUrl = context && context.url ? context.url : "";
const isAuthenticatedRoute = /\/(app|admin)(\/|$)/.test(new URL(targetUrl).pathname);
if (!isAuthenticatedRoute || !sessionCookie) {
return;
}
const parsed = new URL(targetUrl);
const domain = process.env.LIGHTHOUSE_COOKIE_DOMAIN || parsed.hostname;
// Set the session cookie before LHCI navigates to the URL.
const page = await browser.newPage();
await page.setCookie({
name: sessionCookieName,
value: sessionCookie,
domain,
path: "/",
httpOnly: true,
secure: parsed.protocol === "https:",
});
await page.close();
};
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import { runApiKeyMigrationCli } from "./migrate-api-keys.js";
import type { ApiKeyMigrationCliDependencies } from "./migrate-api-keys.js";
function createOutputBuffer(): Pick<typeof process.stdout, "write"> & { text: () => string } {
const chunks: string[] = [];
return {
write: vi.fn((chunk: string | Uint8Array) => {
chunks.push(String(chunk));
return true;
}) as unknown as typeof process.stdout.write,
text: () => chunks.join(""),
};
}
function createDependencies(overrides: Partial<ApiKeyMigrationCliDependencies> = {}) {
const stdout = createOutputBuffer();
const stderr = createOutputBuffer();
const backfillLegacyApiKeyHashes = vi.fn().mockResolvedValue({ scanned: 0, updated: 0, skipped: 0, failed: 0, failures: [] });
const dependencies = {
stdout,
stderr,
backfillLegacyApiKeyHashes,
...overrides,
};
return dependencies as typeof dependencies & {
stdout: typeof stdout;
stderr: typeof stderr;
backfillLegacyApiKeyHashes: typeof backfillLegacyApiKeyHashes;
};
}
describe("runApiKeyMigrationCli", () => {
it("defaults to dry-run and performs no writes", async () => {
const dependencies = createDependencies();
await expect(runApiKeyMigrationCli([], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(0);
expect(dependencies.backfillLegacyApiKeyHashes).not.toHaveBeenCalled();
expect(JSON.parse(dependencies.stdout.text())).toMatchObject({
mode: "dry-run",
scanned: 0,
updated: 0,
skipped: 0,
failed: 0,
});
expect(dependencies.stdout.text()).not.toContain("fixture-hash-secret");
expect(dependencies.stderr.text()).toBe("");
});
it("executes the backfill with caller batch size and resolved hash secret", async () => {
const dependencies = createDependencies({
backfillLegacyApiKeyHashes: vi.fn().mockResolvedValue({
scanned: 3,
updated: 2,
skipped: 1,
failed: 0,
failures: [],
}),
});
await expect(
runApiKeyMigrationCli(["--execute", "--batch-size", "3"], { API_KEY_HASH_SECRET: "fixture-hash-secret", JWT_SECRET: "fallback-secret" }, dependencies),
).resolves.toBe(0);
expect(dependencies.backfillLegacyApiKeyHashes).toHaveBeenCalledWith({ batchSize: 3, hashSecret: "fixture-hash-secret" });
expect(JSON.parse(dependencies.stdout.text())).toEqual({
mode: "execute",
batchSize: 3,
scanned: 3,
updated: 2,
skipped: 1,
failed: 0,
failureIds: [],
});
expect(dependencies.stdout.text()).not.toContain("fixture-hash-secret");
expect(dependencies.stdout.text()).not.toContain("fallback-secret");
});
it("falls back to JWT_SECRET when API_KEY_HASH_SECRET is blank", async () => {
const dependencies = createDependencies();
await expect(runApiKeyMigrationCli(["--execute"], { API_KEY_HASH_SECRET: " ", JWT_SECRET: "jwt-fallback-secret" }, dependencies)).resolves.toBe(0);
expect(dependencies.backfillLegacyApiKeyHashes).toHaveBeenCalledWith({ batchSize: 100, hashSecret: "jwt-fallback-secret" });
expect(dependencies.stdout.text()).not.toContain("jwt-fallback-secret");
});
it("rejects invalid batch-size before DB writes", async () => {
const dependencies = createDependencies();
await expect(runApiKeyMigrationCli(["--execute", "--batch-size", "0"], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(1);
expect(dependencies.backfillLegacyApiKeyHashes).not.toHaveBeenCalled();
expect(dependencies.stderr.text()).toContain("Invalid --batch-size");
expect(dependencies.stderr.text()).not.toContain("fixture-hash-secret");
expect(dependencies.stdout.text()).toBe("");
});
it("redacts raw fixture secrets from failed summaries", async () => {
const rawKey = "api_test_plaintext_secret";
const dependencies = createDependencies({
backfillLegacyApiKeyHashes: vi.fn().mockResolvedValue({
scanned: 1,
updated: 0,
skipped: 0,
failed: 1,
failures: [{ id: "api_key_1", error: `database leaked ${rawKey}` }],
}),
});
await expect(runApiKeyMigrationCli(["--execute"], { JWT_SECRET: "jwt-fallback-secret" }, dependencies)).resolves.toBe(1);
expect(dependencies.stdout.text()).toContain("api_key_1");
expect(dependencies.stdout.text()).not.toContain(rawKey);
expect(dependencies.stdout.text()).not.toContain("jwt-fallback-secret");
expect(dependencies.stderr.text()).toBe("");
});
it("redacts thrown migration helper errors", async () => {
const rawKey = "api_test_plaintext_secret";
const dependencies = createDependencies({
backfillLegacyApiKeyHashes: vi.fn().mockRejectedValue(new Error(`connection failed for ${rawKey}`)),
});
await expect(runApiKeyMigrationCli(["--execute"], { API_KEY_HASH_SECRET: "fixture-hash-secret" }, dependencies)).resolves.toBe(1);
expect(dependencies.stderr.text()).toContain("Error");
expect(dependencies.stderr.text()).not.toContain(rawKey);
expect(dependencies.stderr.text()).not.toContain("fixture-hash-secret");
expect(dependencies.stdout.text()).toBe("");
});
});
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env tsx
import { backfillLegacyApiKeyHashes, DEFAULT_LEGACY_API_KEY_HASH_BACKFILL_BATCH_SIZE } from "@toolctrl/db/api-keys.server";
import type { LegacyApiKeyHashBackfillResult } from "@toolctrl/db/api-keys.server";
export type ApiKeyMigrationCliEnv = NodeJS.ProcessEnv & {
API_KEY_HASH_SECRET?: string;
JWT_SECRET?: string;
};
export type ApiKeyMigrationCliDependencies = {
backfillLegacyApiKeyHashes?: typeof backfillLegacyApiKeyHashes;
stdout?: Pick<typeof process.stdout, "write">;
stderr?: Pick<typeof process.stderr, "write">;
};
type ApiKeyMigrationCliOptions = {
dryRun: boolean;
batchSize: number;
};
function parseApiKeyMigrationArgs(argv: string[]): ApiKeyMigrationCliOptions {
let dryRun = true;
let batchSize = DEFAULT_LEGACY_API_KEY_HASH_BACKFILL_BATCH_SIZE;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--dry-run") {
dryRun = true;
continue;
}
if (arg === "--execute") {
dryRun = false;
continue;
}
if (arg === "--batch-size") {
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
throw new Error("Invalid --batch-size: expected a positive integer.");
}
batchSize = parseBatchSize(value);
index += 1;
continue;
}
if (arg.startsWith("--batch-size=")) {
batchSize = parseBatchSize(arg.slice("--batch-size=".length));
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { dryRun, batchSize };
}
function parseBatchSize(value: string): number {
if (!/^\d+$/.test(value)) {
throw new Error("Invalid --batch-size: expected a positive integer.");
}
const batchSize = Number(value);
if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
throw new Error("Invalid --batch-size: expected a positive integer.");
}
return batchSize;
}
function resolveHashSecret(env: ApiKeyMigrationCliEnv): string | undefined {
return env.API_KEY_HASH_SECRET?.trim() || env.JWT_SECRET?.trim() || undefined;
}
function writeLine(stream: Pick<typeof process.stdout, "write">, value: string): void {
stream.write(`${value}\n`);
}
function summarizeResult(result: LegacyApiKeyHashBackfillResult): Omit<LegacyApiKeyHashBackfillResult, "failures"> & { failureIds: string[] } {
return {
scanned: result.scanned,
updated: result.updated,
skipped: result.skipped,
failed: result.failed,
failureIds: result.failures.map((failure) => failure.id),
};
}
function redactCliError(error: unknown): string {
if (error instanceof Error) {
return error.name || "Error";
}
return "UnknownError";
}
export async function runApiKeyMigrationCli(
argv: string[] = process.argv.slice(2),
env: ApiKeyMigrationCliEnv = process.env,
dependencies: ApiKeyMigrationCliDependencies = {},
): Promise<number> {
const stdout = dependencies.stdout ?? process.stdout;
const stderr = dependencies.stderr ?? process.stderr;
const migrate = dependencies.backfillLegacyApiKeyHashes ?? backfillLegacyApiKeyHashes;
let options: ApiKeyMigrationCliOptions;
try {
options = parseApiKeyMigrationArgs(argv);
} catch (error) {
writeLine(stderr, error instanceof Error ? error.message : "Invalid arguments.");
return 1;
}
if (options.dryRun) {
writeLine(
stdout,
JSON.stringify({
mode: "dry-run",
batchSize: options.batchSize,
scanned: 0,
updated: 0,
skipped: 0,
failed: 0,
message: "Dry run only; no database writes performed. Use --execute to apply the backfill.",
}),
);
return 0;
}
try {
const result = await migrate({ batchSize: options.batchSize, hashSecret: resolveHashSecret(env) });
writeLine(stdout, JSON.stringify({ mode: "execute", batchSize: options.batchSize, ...summarizeResult(result) }));
return result.failed > 0 ? 1 : 0;
} catch (error) {
writeLine(
stderr,
JSON.stringify({ mode: "execute", batchSize: options.batchSize, scanned: 0, updated: 0, skipped: 0, failed: 1, error: redactCliError(error) }),
);
return 1;
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
runApiKeyMigrationCli()
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch(() => {
process.exitCode = 1;
});
}
@@ -0,0 +1,287 @@
// One-shot migration: rewrite every hand-authored SVG icon under
// apps/web/app/components/ui/icons/** to re-export a Subframe `@subframe/core`
// Feather icon via the `featherIcon` wrapper (which preserves the
// `({ className }: { className?: string })` public API so every call site keeps
// working unchanged).
//
// Run from repo root: node scripts/migrate-icons-to-feather.mjs
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, "..")
const iconsRoot = join(repoRoot, "apps/web/app/components/ui/icons")
// Validated list of every Feather export in @subframe/core@1.155.0.
const featherNames = new Set(
readFileSync("/tmp/feather-names.txt", "utf8").split("\n").map((s) => s.trim()).filter(Boolean)
)
// relative path (under icons/) -> Feather component name.
// Every target verified to exist in featherNames before any file is written.
const MAP = {
// --- top level ---
"ArrowsPointingIn.tsx": "FeatherShrink",
"ArrowsPointingOut.tsx": "FeatherExpand",
"AtSymbolIcon.tsx": "FeatherAtSign",
"BoardIcon.tsx": "FeatherTrello",
"BoardIconFilled.tsx": "FeatherTrello",
"BookmarkIcon.tsx": "FeatherBookmark",
"BookmarkIconFilled.tsx": "FeatherBookmark",
"BrilliantIdeaIcon.tsx": "FeatherLightbulb",
"BrilliantIdeaIconFilled.tsx": "FeatherLightbulb",
"CacheIcon.tsx": "FeatherDatabase",
"CalendarFilledIcon.tsx": "FeatherCalendar",
"CalendarIcon.tsx": "FeatherCalendar",
"ChatAltIcon.tsx": "FeatherMessageSquare",
"CheckEmptyCircleIcon.tsx": "FeatherCircle",
"CheckFilledCircleIcon.tsx": "FeatherCheckCircle",
"CheckIcon.tsx": "FeatherCheck",
"ChevronDownIcon.tsx": "FeatherChevronDown",
"ChevronLeftIcon.tsx": "FeatherChevronLeft",
"ClipboardIcon.tsx": "FeatherClipboard",
"ClockIcon.tsx": "FeatherClock",
"ClockIconFilled.tsx": "FeatherClock",
"ComboChartIcon.tsx": "FeatherBarChart2",
"CookieIcon.tsx": "FeatherCookie",
"CreditsIcon.tsx": "FeatherCoins",
"CreditsIconFilled.tsx": "FeatherCoins",
"CurrencyIcon.tsx": "FeatherDollarSign",
"DataSourceIcon.tsx": "FeatherDatabase",
"DataSourceIconFilled.tsx": "FeatherDatabase",
"DataViewIcon.tsx": "FeatherTable",
"DataViewIconFilled.tsx": "FeatherTable",
"DocumentDuplicateIcon.tsx": "FeatherCopy",
"DocumentDuplicateIconFilled.tsx": "FeatherCopy",
"DotsHorizontalEmptyIcon.tsx": "FeatherMoreHorizontal",
"DotsHorizontalFilledIcon.tsx": "FeatherMoreHorizontal",
"DotsVerticalEmptyIcon.tsx": "FeatherMoreVertical",
"DotsVerticalFilledIcon.tsx": "FeatherMoreVertical",
"DownArrow.tsx": "FeatherArrowDown",
"DownloadIcon.tsx": "FeatherDownload",
"EmailIcon.tsx": "FeatherMail",
"ExclamationTriangleIcon.tsx": "FeatherAlertTriangle",
"ExclamationTriangleIconFilled.tsx": "FeatherAlertTriangle",
"ExternalLinkEmptyIcon.tsx": "FeatherExternalLink",
"ExternalLinkFilledIcon.tsx": "FeatherExternalLink",
"EyeIcon.tsx": "FeatherEye",
"EyeIconFilled.tsx": "FeatherEye",
"EyeLashIcon.tsx": "FeatherEyeOff",
"EyeLashIconFilled.tsx": "FeatherEyeOff",
"FeedbackIcon.tsx": "FeatherMessageSquare",
"FeedbackIconFilled.tsx": "FeatherMessageSquare",
"FilterEmptyIcon.tsx": "FeatherFilter",
"FilterFilledIcon.tsx": "FeatherFilter",
"GearIcon.tsx": "FeatherSettings",
"GearIconFilled.tsx": "FeatherSettings",
"GitHubIcon.tsx": "FeatherGithub",
"GoogleIcon.tsx": "FeatherChrome",
"IconEmptyResults.tsx": "FeatherInbox",
"LanguageIcon.tsx": "FeatherLanguages",
"LightningBoltEmptyIcon.tsx": "FeatherZap",
"LightningBoltFilledIcon.tsx": "FeatherZap",
"LockClosedIcon.tsx": "FeatherLock",
"LockOpenedIcon.tsx": "FeatherUnlock",
"MagnifyingGlassIcon.tsx": "FeatherSearch",
"MegaphoneFilled.tsx": "FeatherMegaphone",
"MembershipCardIcon.tsx": "FeatherCreditCard",
"NewFieldIcon.tsx": "FeatherPlusSquare",
// NOTE: OpenCloseArrowIcon.tsx is intentionally NOT listed here. It is a
// stateful toggle button ({ open, setOpen }), not a bare `{ className }` icon,
// so it is migrated by hand (renders Feather chevrons inside the button).
"OptionsIcon.tsx": "FeatherSliders",
"PaperClipIcon.tsx": "FeatherPaperclip",
"PencilAltIcon.tsx": "FeatherEdit3",
"PencilEmptyIcon.tsx": "FeatherEdit2",
"PencilIcon.tsx": "FeatherEdit2",
"PlusIcon.tsx": "FeatherPlus",
"PreviewIcon.tsx": "FeatherEye",
"QuestionMarkFilledIcon.tsx": "FeatherHelpCircle",
"RefreshIcon.tsx": "FeatherRefreshCw",
"RightArrowIcon.tsx": "FeatherArrowRight",
"RightIcon.tsx": "FeatherChevronRight",
"SelectorIcon.tsx": "FeatherChevronsUpDown",
"ShareIcon.tsx": "FeatherShare2",
"ShareIconFilled.tsx": "FeatherShare2",
"ShuffleIcon.tsx": "FeatherShuffle",
"ShuffleIconFilled.tsx": "FeatherShuffle",
"SortUpIcon.tsx": "FeatherChevronUp",
"SortUpIconFilled.tsx": "FeatherChevronUp",
"StarsIcon.tsx": "FeatherSparkles",
"StarsIconFilled.tsx": "FeatherSparkles",
"StripeIcon.tsx": "FeatherCreditCard",
"TableIcon.tsx": "FeatherTable",
"TagEmptyIcon.tsx": "FeatherTag",
"TagFilledIcon.tsx": "FeatherTag",
"ThumbsDownIcon.tsx": "FeatherThumbsDown",
"ThumbsDownIconFilled.tsx": "FeatherThumbsDown",
"ThumbsUpEmptyIcon.tsx": "FeatherThumbsUp",
"ThumbsUpFilledIcon.tsx": "FeatherThumbsUp",
"ThumbsUpIcon.tsx": "FeatherThumbsUp",
"ThumbsUpIconFilled.tsx": "FeatherThumbsUp",
"TimeMachineFilled.tsx": "FeatherHistory",
"TimeMachineIcon.tsx": "FeatherHistory",
"TrashEmptyIcon.tsx": "FeatherTrash2",
"TrashIcon.tsx": "FeatherTrash2",
"UpArrow.tsx": "FeatherArrowUp",
"UserGroupIcon.tsx": "FeatherUsers",
"UserGroupIconFilled.tsx": "FeatherUsers",
"UserIcon.tsx": "FeatherUser",
"VariableIcon.tsx": "FeatherVariable",
"ViewBoardsEmptyIcon.tsx": "FeatherColumns",
"ViewBoardsIcon.tsx": "FeatherColumns",
"ViewListIcon.tsx": "FeatherList",
"WebsiteIcon.tsx": "FeatherGlobe",
"WebsiteIconFilled.tsx": "FeatherGlobe",
"XIcon.tsx": "FeatherX",
"YoutubeIcon.tsx": "FeatherYoutube",
// --- ai/ ---
"ai/ChatGptIcon.tsx": "FeatherBot",
// --- crm/ ---
"crm/AddressBookIcon.tsx": "FeatherContact",
"crm/AddressBookIconFilled.tsx": "FeatherContact",
"crm/CompanyIcon.tsx": "FeatherBuilding2",
"crm/CompanyIconFilled.tsx": "FeatherBuilding2",
"crm/FlowIcon.tsx": "FeatherWorkflow",
"crm/FlowIconFilled.tsx": "FeatherWorkflow",
"crm/FormIcon.tsx": "FeatherClipboardList",
"crm/FormIconFilled.tsx": "FeatherClipboardList",
"crm/IncreaseIcon.tsx": "FeatherTrendingUp",
"crm/IncreaseIconFilled.tsx": "FeatherTrendingUp",
"crm/SettingsIcon.tsx": "FeatherSettings",
"crm/SettingsIconFilled.tsx": "FeatherSettings",
"crm/UsDollarCircled.tsx": "FeatherCircleDollarSign",
"crm/UsDollarCircledFilled.tsx": "FeatherCircleDollarSign",
// --- crud/ ---
"crud/DownloadIcon.tsx": "FeatherDownload",
"crud/DownloadIconFilled.tsx": "FeatherDownload",
"crud/EditIcon.tsx": "FeatherEdit2",
"crud/EditIconFilled.tsx": "FeatherEdit2",
"crud/LinkIcon.tsx": "FeatherLink",
"crud/LinkIconFilled.tsx": "FeatherLink",
"crud/PrototypeIcon.tsx": "FeatherBox",
"crud/PrototypeIconFilled.tsx": "FeatherBox",
"crud/ShareIcon.tsx": "FeatherShare2",
"crud/ShareIconFilled.tsx": "FeatherShare2",
"crud/TagsIcon.tsx": "FeatherTags",
"crud/TagsIconFilled.tsx": "FeatherTags",
"crud/UploadIcon.tsx": "FeatherUpload",
"crud/UploadIconFilled.tsx": "FeatherUpload",
// --- emails/ ---
"emails/InboxIcon.tsx": "FeatherInbox",
"emails/InboxIconFilled.tsx": "FeatherInbox",
"emails/MegaphoneIcon.tsx": "FeatherMegaphone",
"emails/MegaphoneIconFilled.tsx": "FeatherMegaphone",
"emails/SentIcon.tsx": "FeatherSend",
"emails/SentIconFilled.tsx": "FeatherSend",
// --- entities/ ---
"entities/ActivityHistoryIcon.tsx": "FeatherActivity",
"entities/ActivityHistoryIconFilled.tsx": "FeatherActivity",
"entities/CodeFileIcon.tsx": "FeatherFileCode",
"entities/CodeFileIconFilled.tsx": "FeatherFileCode",
"entities/CodingIcon.tsx": "FeatherCode",
"entities/CodingIconFilled.tsx": "FeatherCode",
"entities/DocsIcon.tsx": "FeatherFileText",
"entities/DocsIconFilled.tsx": "FeatherFileText",
"entities/FolderIcon.tsx": "FeatherFolder",
"entities/FolderIconFilled.tsx": "FeatherFolder",
"entities/KeyboardIcon.tsx": "FeatherKeyboard",
"entities/KeyboardIconFilled.tsx": "FeatherKeyboard",
"entities/KeyIcon.tsx": "FeatherKey",
"entities/KeyIconFilled.tsx": "FeatherKey",
"entities/ListIcon.tsx": "FeatherList",
"entities/ListIconFilled.tsx": "FeatherList",
"entities/ModulesIcon.tsx": "FeatherPackage",
"entities/ModulesIconFilled.tsx": "FeatherPackage",
"entities/PlaceholderThumbnailXmlIcon.tsx": "FeatherImage",
"entities/PlaceholderThumbnailXmlIconFilled.tsx": "FeatherImage",
"entities/RectangleGroupIcon.tsx": "FeatherLayout",
"entities/RectangleGroupIconFilled.tsx": "FeatherLayout",
"entities/RestApiIcon.tsx": "FeatherServer",
"entities/RestApiIconFilled.tsx": "FeatherServer",
"entities/RowsIcon.tsx": "FeatherRows3",
"entities/RowsIconFilled.tsx": "FeatherRows3",
"entities/TemplateIcon.tsx": "FeatherLayoutTemplate",
"entities/TemplateIconFilled.tsx": "FeatherLayoutTemplate",
"entities/TreeStructureFilledIcon.tsx": "FeatherNetwork",
"entities/TreeStructureIcon.tsx": "FeatherNetwork",
// --- featureFlags/ ---
"featureFlags/ToggleOffIcon.tsx": "FeatherToggleLeft",
"featureFlags/ToggleOnIcon.tsx": "FeatherToggleRight",
// --- knowledgeBase/ ---
"knowledgeBase/BookStackIcon.tsx": "FeatherBookOpen",
"knowledgeBase/BookStackIconFilled.tsx": "FeatherBookOpen",
// --- notifications/ ---
"notifications/AlarmIcon.tsx": "FeatherBell",
"notifications/AlarmIconFilled.tsx": "FeatherBell",
"notifications/ChannelIcon.tsx": "FeatherRadio",
"notifications/ChannelIconFilled.tsx": "FeatherRadio",
"notifications/EarIcon.tsx": "FeatherEar",
"notifications/EarIconFilled.tsx": "FeatherEar",
// --- onboardings/ ---
"onboardings/BycicleIcon.tsx": "FeatherBike",
"onboardings/GoalIcon.tsx": "FeatherTarget",
"onboardings/GoalIconFilled.tsx": "FeatherTarget",
"onboardings/JourneyIcon.tsx": "FeatherRoute",
"onboardings/JourneyIconFilled.tsx": "FeatherRoute",
// --- pages/ ---
"pages/BlockIcon.tsx": "FeatherSquare",
"pages/BlockIconFilled.tsx": "FeatherSquare",
// --- settings/ ---
"settings/AccessDeniedIcon.tsx": "FeatherShieldOff",
"settings/AccessDeniedIconFilled.tsx": "FeatherShieldOff",
"settings/CustomerIcon.tsx": "FeatherUser",
"settings/CustomerIconFilled.tsx": "FeatherUser",
"settings/GroupIcon.tsx": "FeatherUsers",
"settings/GroupIconFilled.tsx": "FeatherUsers",
"settings/MembershipCardIcon.tsx": "FeatherCreditCard",
"settings/MembershipCardIconFilled.tsx": "FeatherCreditCard",
"settings/PeopleIcon.tsx": "FeatherUsers",
"settings/PeopleIconFilled.tsx": "FeatherUsers",
// --- tests/ ---
"tests/ExperimentIcon.tsx": "FeatherFlaskConical",
"tests/ExperimentIconFilled.tsx": "FeatherFlaskConical",
}
const entries = Object.entries(MAP)
const errors = []
// 1) validate every target Feather name actually exists.
for (const [file, feather] of entries) {
if (!featherNames.has(feather)) errors.push(`UNKNOWN FEATHER: ${feather} (for ${file})`)
if (!existsSync(join(iconsRoot, file))) errors.push(`MISSING FILE: ${file}`)
}
if (errors.length) {
console.error("Validation failed:\n" + errors.join("\n"))
process.exit(1)
}
// 2) rewrite every file.
let written = 0
for (const [file, feather] of entries) {
const abs = join(iconsRoot, file)
// helper import path: top-level => "./_feather", nested => "../_feather"
const depth = file.split("/").length - 1
const helperPath = depth === 0 ? "./_feather" : "../".repeat(depth) + "_feather"
const content =
`import { ${feather} } from "@subframe/core"\n\n` +
`import { featherIcon } from "${helperPath}"\n\n` +
`export default featherIcon(${feather})\n`
writeFileSync(abs, content, "utf8")
written++
}
console.log(`Rewrote ${written}/${entries.length} icon files to Feather.`)
@@ -0,0 +1,94 @@
// Migrates the two remaining single-glyph icon-component directories to Subframe
// Feather icons via the shared icons/_feather.tsx wrapper:
// - apps/web/app/components/layouts/icons/** (sidebar / nav icons)
// - apps/web/app/modules/workflowEngine/components/icons/**
//
// Same contract as scripts/migrate-icons-to-feather.mjs: each file keeps its
// default-export `({ className }: { className?: string })` API so all call sites
// stay unchanged. Dynamic renderers (EntityIcon, SidebarIcon) and test files are
// intentionally excluded.
//
// Run from repo root: node scripts/migrate-layout-workflow-icons-to-feather.mjs
import { readFileSync, writeFileSync, existsSync } from "node:fs"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, "..")
const appRoot = join(repoRoot, "apps/web/app")
const featherNames = new Set(
readFileSync("/tmp/feather-names.txt", "utf8").split("\n").map((s) => s.trim()).filter(Boolean)
)
// relative path (under apps/web/app) -> Feather component name.
const MAP = {
// --- components/layouts/icons (sidebar/nav) ---
"components/layouts/icons/IconAdmin.tsx": "FeatherShield",
"components/layouts/icons/IconAnalytics.tsx": "FeatherBarChart2",
"components/layouts/icons/IconApp.tsx": "FeatherLayoutGrid",
"components/layouts/icons/IconClients.tsx": "FeatherUsers",
"components/layouts/icons/IconCode.tsx": "FeatherCode",
"components/layouts/icons/IconComponents.tsx": "FeatherComponent",
"components/layouts/icons/IconDashboard.tsx": "FeatherLayoutDashboard",
"components/layouts/icons/IconDocs.tsx": "FeatherFileText",
"components/layouts/icons/IconEmails.tsx": "FeatherMail",
"components/layouts/icons/IconEntities.tsx": "FeatherDatabase",
"components/layouts/icons/IconEvents.tsx": "FeatherCalendar",
"components/layouts/icons/IconFeatureFlags.tsx": "FeatherFlag",
"components/layouts/icons/IconHelpDesk.tsx": "FeatherLifeBuoy",
"components/layouts/icons/IconHistory.tsx": "FeatherHistory",
"components/layouts/icons/IconKeys.tsx": "FeatherKey",
"components/layouts/icons/IconLinks.tsx": "FeatherLink",
"components/layouts/icons/IconMembers.tsx": "FeatherUsers",
"components/layouts/icons/IconMetrics.tsx": "FeatherActivity",
"components/layouts/icons/IconNavigation.tsx": "FeatherCompass",
"components/layouts/icons/IconNotifications.tsx": "FeatherBell",
"components/layouts/icons/IconOnboarding.tsx": "FeatherRocket",
"components/layouts/icons/IconPages.tsx": "FeatherFile",
"components/layouts/icons/IconPortals.tsx": "FeatherGlobe",
"components/layouts/icons/IconProfile.tsx": "FeatherUser",
"components/layouts/icons/IconPromptBuilder.tsx": "FeatherSparkles",
"components/layouts/icons/IconProviders.tsx": "FeatherPlug",
"components/layouts/icons/IconRoles.tsx": "FeatherShield",
"components/layouts/icons/IconSettings.tsx": "FeatherSettings",
"components/layouts/icons/IconTenants.tsx": "FeatherBuilding2",
"components/layouts/icons/IconUsers.tsx": "FeatherUsers",
"components/layouts/icons/IconWidgets.tsx": "FeatherBox",
"components/layouts/icons/IconWorkflows.tsx": "FeatherWorkflow",
// --- modules/workflowEngine/components/icons ---
"modules/workflowEngine/components/icons/WorkflowIcon.tsx": "FeatherWorkflow",
"modules/workflowEngine/components/icons/WorkflowIconFilled.tsx": "FeatherWorkflow",
"modules/workflowEngine/components/icons/WorkflowBlockActionIcon.tsx": "FeatherBox",
"modules/workflowEngine/components/icons/WorkflowCredentialIcon.tsx": "FeatherKey",
"modules/workflowEngine/components/icons/WorkflowCredentialIconFilled.tsx": "FeatherKey",
"modules/workflowEngine/components/icons/WorkflowExecutionIcon.tsx": "FeatherPlay",
"modules/workflowEngine/components/icons/WorkflowExecutionIconFilled.tsx": "FeatherPlay",
"modules/workflowEngine/components/icons/WorkflowVariableIcon.tsx": "FeatherVariable",
"modules/workflowEngine/components/icons/WorkflowVariableIconFilled.tsx": "FeatherVariable",
}
const entries = Object.entries(MAP)
const errors = []
for (const [file, feather] of entries) {
if (!featherNames.has(feather)) errors.push(`UNKNOWN FEATHER: ${feather} (for ${file})`)
if (!existsSync(join(appRoot, file))) errors.push(`MISSING FILE: ${file}`)
}
if (errors.length) {
console.error("Validation failed:\n" + errors.join("\n"))
process.exit(1)
}
let written = 0
for (const [file, feather] of entries) {
const abs = join(appRoot, file)
const content =
`import { ${feather} } from "@subframe/core"\n\n` +
`import { featherIcon } from "~/components/ui/icons/_feather"\n\n` +
`export default featherIcon(${feather})\n`
writeFileSync(abs, content, "utf8")
written++
}
console.log(`Rewrote ${written}/${entries.length} layout/workflow icon files to Feather.`)
@@ -0,0 +1,290 @@
// Generates apps/web/app/components/ui/icons/vendor.tsx — a compatibility shim
// that re-exports every vendor icon used across the app (lucide-react,
// @radix-ui/react-icons, @heroicons/react/24/outline) as a Subframe
// `@subframe/core` Feather icon. Feather IS the Lucide icon set, so the swap is
// visually lossless for lucide and a close match for radix/hero.
//
// The shim presents a lucide-compatible prop surface ({ className, size, ... })
// and bridges Feather's font-size sizing model onto vendor call sites that size
// via className (`size-4`, `h-4 w-4`) or the `size` prop, using the same
// `[&>svg]:size-full` technique as icons/_feather.tsx.
//
// After generating, run scripts/swap-vendor-icon-imports.mjs to repoint imports.
//
// Run from repo root: node scripts/migrate-vendor-icons-to-feather.mjs
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, "..")
const appRoot = join(repoRoot, "apps/web/app")
const outFile = join(appRoot, "components/ui/icons/vendor.tsx")
const featherNames = new Set(
readFileSync("/tmp/feather-names.txt", "utf8").split("\n").map((s) => s.trim()).filter(Boolean)
)
// vendor identifier (as imported) -> Feather component name.
const MAP = {
// arrows / chevrons / carets
ArrowDownIcon: "FeatherArrowDown",
ArrowLeftIcon: "FeatherArrowLeft",
ArrowRightIcon: "FeatherArrowRight",
ChevronDown: "FeatherChevronDown",
ChevronDownIcon: "FeatherChevronDown",
ChevronUpIcon: "FeatherChevronUp",
ChevronLeftIcon: "FeatherChevronLeft",
ChevronRight: "FeatherChevronRight",
ChevronRightIcon: "FeatherChevronRight",
ChevronsUpDown: "FeatherChevronsUpDown",
CaretDownIcon: "FeatherChevronDown",
CaretSortIcon: "FeatherChevronsUpDown",
// checks / circles / dots / badges
Check: "FeatherCheck",
CheckIcon: "FeatherCheck",
CheckCircleIcon: "FeatherCheckCircle",
CircleIcon: "FeatherCircle",
DotIcon: "FeatherDot",
DotsHorizontalIcon: "FeatherMoreHorizontal",
XCircle: "FeatherXCircle",
XCircleIcon: "FeatherXCircle",
BadgeCheck: "FeatherBadgeCheck",
// x / close
XIcon: "FeatherX",
Cross2Icon: "FeatherX",
// more / overflow
MoreHorizontal: "FeatherMoreHorizontal",
MoreHorizontalIcon: "FeatherMoreHorizontal",
MoreVerticalIcon: "FeatherMoreVertical",
MessageCircleIcon: "FeatherMessageCircle",
// common ui
CreditCard: "FeatherCreditCard",
LogOut: "FeatherLogOut",
BellIcon: "FeatherBell",
FileTextIcon: "FeatherFileText",
PaintbrushIcon: "FeatherPaintbrush",
PlugIcon: "FeatherPlug",
ShieldCheckIcon: "FeatherShieldCheck",
BookIcon: "FeatherBook",
BotIcon: "FeatherBot",
BrainIcon: "FeatherBrain",
CalendarIcon: "FeatherCalendar",
Calendar: "FeatherCalendar",
ClockIcon: "FeatherClock",
WrenchIcon: "FeatherWrench",
ClipboardIcon: "FeatherClipboard",
EyeIcon: "FeatherEye",
CopyIcon: "FeatherCopy",
RefreshCcwIcon: "FeatherRefreshCcw",
LucideRefreshCcw: "FeatherRefreshCcw",
ReloadIcon: "FeatherRefreshCw",
ExternalLinkIcon: "FeatherExternalLink",
ImportIcon: "FeatherDownload",
SunIcon: "FeatherSun",
MoonIcon: "FeatherMoon",
PlusIcon: "FeatherPlus",
Plus: "FeatherPlus",
MinusIcon: "FeatherMinus",
StoreIcon: "FeatherStore",
GripVerticalIcon: "FeatherGripVertical",
ImageIcon: "FeatherImage",
Image: "FeatherImage",
Loader2Icon: "FeatherLoader2",
LoaderCircleIcon: "FeatherLoader2",
PaperclipIcon: "FeatherPaperclip",
SendIcon: "FeatherSend",
SquareIcon: "FeatherSquare",
LinkIcon: "FeatherLink",
LockIcon: "FeatherLock",
MenuIcon: "FeatherMenu",
PanelLeftIcon: "FeatherPanelLeft",
MessageSquareIcon: "FeatherMessageSquare",
TrashIcon: "FeatherTrash2",
SearchIcon: "FeatherSearch",
SparklesIcon: "FeatherSparkles",
WandSparkles: "FeatherWandSparkles",
GearIcon: "FeatherSettings",
// text editor (novel) formatting marks
Bold: "FeatherBold",
BoldIcon: "FeatherBold",
Italic: "FeatherItalic",
ItalicIcon: "FeatherItalic",
Underline: "FeatherUnderline",
UnderlineIcon: "FeatherUnderline",
Strikethrough: "FeatherStrikethrough",
Code: "FeatherCode",
CodeIcon: "FeatherCode",
List: "FeatherList",
ListOrdered: "FeatherListOrdered",
Quote: "FeatherQuote",
Undo: "FeatherUndo",
Redo: "FeatherRedo",
Eraser: "FeatherEraser",
EraserIcon: "FeatherEraser",
Link2: "FeatherLink2",
Heading1: "FeatherHeading1",
Heading2: "FeatherHeading2",
Heading3: "FeatherHeading3",
Text: "FeatherText",
TextIcon: "FeatherText",
TextQuote: "FeatherTextQuote",
// heroicons
NoSymbolIcon: "FeatherSlash",
ServerStackIcon: "FeatherServer",
// Additional Feather icons used by the inline-`<svg>` → Feather migration
// (raw hand-authored glyphs across feature components). Not imported from a
// vendor library directly, but exposed here so those call sites can use the
// same shim. See docs/subframe/INLINE-SVG-MIGRATION.md.
HomeIcon: "FeatherHome",
StarIcon: "FeatherStar",
MailIcon: "FeatherMail",
PhoneIcon: "FeatherPhone",
AlertTriangleIcon: "FeatherAlertTriangle",
ArrowUpIcon: "FeatherArrowUp",
GlobeIcon: "FeatherGlobe",
LanguagesIcon: "FeatherLanguages",
HelpCircleIcon: "FeatherHelpCircle",
InfoIcon: "FeatherInfo",
TrendingUpIcon: "FeatherTrendingUp",
TrendingDownIcon: "FeatherTrendingDown",
AtSignIcon: "FeatherAtSign",
PilcrowIcon: "FeatherPilcrow",
EditIcon: "FeatherEdit2",
PencilIcon: "FeatherEdit2",
PlayIcon: "FeatherPlay",
VariableIcon: "FeatherVariable",
ChevronsRightIcon: "FeatherChevronsRight",
ArrowLeftRightIcon: "FeatherArrowLeftRight",
UserIcon: "FeatherUser",
}
const VENDOR_SOURCES = ["lucide-react", "@radix-ui/react-icons", "@heroicons/react/24/outline"]
// --- collect every value identifier actually imported from the vendor sources ---
function walk(dir, acc = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry.startsWith(".")) continue
const full = join(dir, entry)
const st = statSync(full)
if (st.isDirectory()) walk(full, acc)
else if (/\.(tsx?|jsx?)$/.test(entry)) acc.push(full)
}
return acc
}
const usedValueIdents = new Set()
const importRe = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*["']([^"']+)["']/g
for (const file of walk(appRoot)) {
const src = readFileSync(file, "utf8")
let m
while ((m = importRe.exec(src))) {
const source = m[2]
if (!VENDOR_SOURCES.includes(source)) continue
for (const raw of m[1].split(",")) {
let spec = raw.trim()
if (!spec) continue
if (spec.startsWith("type ")) continue // a type-only specifier (e.g. `type LucideIcon`)
// handle `Foo as Bar` -> the imported name is `Foo`
const asMatch = spec.match(/^([A-Za-z0-9_]+)\s+as\s+[A-Za-z0-9_]+$/)
const name = asMatch ? asMatch[1] : spec
if (name === "LucideIcon") continue // type, exported separately
usedValueIdents.add(name)
}
}
}
// --- validate ---
const errors = []
for (const name of usedValueIdents) {
if (!MAP[name]) errors.push(`UNMAPPED vendor identifier: ${name}`)
}
for (const [name, feather] of Object.entries(MAP)) {
if (!featherNames.has(feather)) errors.push(`UNKNOWN Feather: ${feather} (for ${name})`)
}
if (errors.length) {
console.error("Validation failed:\n" + errors.join("\n"))
console.error("\nUsed value identifiers found:", [...usedValueIdents].sort().join(", "))
process.exit(1)
}
// --- generate the shim ---
const entries = Object.entries(MAP).sort(([a], [b]) => a.localeCompare(b))
const feathersUsed = [...new Set(entries.map(([, f]) => f))].sort()
const header = `import * as React from "react"
import {
${feathersUsed.map((f) => ` ${f},`).join("\n")}
} from "@subframe/core"
import { cn } from "~/lib/utils"
// AUTO-GENERATED by scripts/migrate-vendor-icons-to-feather.mjs — do not edit by hand.
//
// Compatibility shim mapping the vendor icons the app used (lucide-react,
// @radix-ui/react-icons, @heroicons) onto Subframe @subframe/core Feather icons.
// Import sources were repointed here so the whole app renders Subframe's icons.
// Feather IS the Lucide set, so lucide swaps are visually identical.
/**
* The subset of lucide-react's prop surface that app call sites use. Feather
* icons render <span><svg width="1em" stroke="currentColor"/></span>, so only
* \`className\`, \`size\`, \`color\` and \`style\` affect rendering; \`strokeWidth\` /
* \`absoluteStrokeWidth\` are accepted (for type-compat) but baked into Feather.
*/
export type LucideProps = React.HTMLAttributes<HTMLSpanElement> & {
size?: number | string
strokeWidth?: number | string
absoluteStrokeWidth?: boolean
color?: string
/** SVG-only attribute some call sites pass; accepted for compat, not forwarded. */
focusable?: boolean | "true" | "false"
}
export type LucideIcon = React.FC<LucideProps>
type FeatherComponent = React.ComponentType<
React.HTMLAttributes<HTMLSpanElement> & React.RefAttributes<HTMLSpanElement>
>
function make(Feather: FeatherComponent): LucideIcon {
function Icon({
className,
size,
color,
style,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
strokeWidth: _strokeWidth,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
absoluteStrokeWidth: _absoluteStrokeWidth,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
focusable: _focusable,
"aria-hidden": ariaHidden,
"aria-label": ariaLabel,
...rest
}: LucideProps) {
const sizeStyle = size != null ? { width: size, height: size } : undefined
// Decorative by default: hide from assistive tech unless the caller gave the
// icon its own accessible name (aria-label) or set aria-hidden explicitly.
const resolvedAriaHidden = ariaHidden ?? (ariaLabel ? undefined : true)
return (
<Feather
aria-hidden={resolvedAriaHidden}
aria-label={ariaLabel}
className={cn("inline-flex shrink-0 [&>svg]:size-full", size == null && "size-4", className)}
style={sizeStyle || color || style ? { ...sizeStyle, color, ...style } : undefined}
{...rest}
/>
)
}
return Icon
}
`
const exportsBlock = entries.map(([name, feather]) => `export const ${name}: LucideIcon = make(${feather})`).join("\n")
writeFileSync(outFile, header + "\n" + exportsBlock + "\n", "utf8")
console.log(`Generated ${outFile}`)
console.log(` ${entries.length} icon exports, ${feathersUsed.length} distinct Feather icons.`)
console.log(` Covered ${usedValueIdents.size} used vendor identifiers.`)
@@ -0,0 +1,181 @@
#!/usr/bin/env -S node --import tsx
/**
* scan-console.ts
*
* Inventory of all `console.*` calls in server-code paths.
*
* Generates docs/observability/console-migration-inventory.md as a Markdown
* table with file:line + snippet for every match outside of test/spec files
* and outside of explicit `// eslint-disable-next-line no-console` lines.
*
* Usage:
* pnpm exec tsx scripts/observability/scan-console.ts
* pnpm exec tsx scripts/observability/scan-console.ts --check # exits 1 if hits > 0
*
* Server paths scanned:
* - apps/api/src/**
* - apps/worker/src/**
* - apps/web/app/**\/*.server.ts(x)
* - apps/web/app/**\/*.server.ts (modules)
* - apps/web/app/modules/**\/services/**
* - apps/web/app/utils/**
* - packages/*\/src/**
*
* Skipped:
* - **\/*.test.ts(x), **\/*.spec.ts(x)
* - lines preceded by `// eslint-disable-next-line no-console`
* - dist/, build/, node_modules/
*/
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, "..", "..");
const OUTPUT = resolve(REPO_ROOT, "docs/observability/console-migration-inventory.md");
const SERVER_GLOBS: string[] = ["apps/api/src", "apps/worker/src", "apps/web/app", "packages"];
const CONSOLE_RE = /console\.(log|error|warn|info|debug|dir|trace)\s*\(/;
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/;
const DISTLIKE_RE = /(^|\/)(node_modules|dist|build|coverage|\.react-router|generated)(\/|$)/;
const SERVER_FILE_RE =
/(\.server\.tsx?|\/\.server\/|apps\/api\/src\/|apps\/worker\/src\/|packages\/[^/]+\/src\/|apps\/web\/app\/utils\/|apps\/web\/app\/modules\/[^/]+\/services\/|apps\/web\/app\/modules\/[^/]+\/db\/|apps\/web\/app\/routes\/.+\.server\.tsx?)/;
interface Hit {
file: string;
line: number;
level: string;
snippet: string;
}
function gitTrackedFiles(): string[] {
const out = execSync("git ls-files", { cwd: REPO_ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024 });
return out.split("\n").filter(Boolean);
}
function isServerPath(file: string): boolean {
if (DISTLIKE_RE.test(file)) return false;
if (TEST_FILE_RE.test(file)) return false;
if (!SERVER_GLOBS.some((g) => file.startsWith(g + "/"))) return false;
// Within apps/web/app: only `.server.ts(x)` files and explicit server folders
if (file.startsWith("apps/web/app/")) {
return SERVER_FILE_RE.test(file);
}
// apps/api/src, apps/worker/src, packages/*/src are entirely server code
return /\.(ts|tsx|mjs|cjs|js)$/.test(file);
}
function scanFile(absPath: string, relPath: string): Hit[] {
let content: string;
try {
content = readFileSync(absPath, "utf8");
} catch {
return [];
}
const hits: Hit[] = [];
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const m = line.match(CONSOLE_RE);
if (!m) continue;
// Skip if line is inside a block comment-only context — too expensive to AST.
// Skip if line is a single-line comment (//) preceding the console call.
const trimmed = line.trimStart();
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
// Skip if previous line is eslint-disable-next-line no-console
const prev = i > 0 ? lines[i - 1].trim() : "";
if (/eslint-disable-next-line[^\n]*no-console/.test(prev)) continue;
// Skip same-line disable
if (/eslint-disable-line[^\n]*no-console/.test(line)) continue;
hits.push({
file: relPath,
line: i + 1,
level: m[1],
snippet: line.trim().slice(0, 160),
});
}
return hits;
}
function main(): void {
const args = process.argv.slice(2);
const checkMode = args.includes("--check");
const files = gitTrackedFiles().filter(isServerPath);
const allHits: Hit[] = [];
for (const rel of files) {
const abs = resolve(REPO_ROOT, rel);
allHits.push(...scanFile(abs, rel));
}
// Group by file for output
const byFile = new Map<string, Hit[]>();
for (const h of allHits) {
const arr = byFile.get(h.file) ?? [];
arr.push(h);
byFile.set(h.file, arr);
}
const sortedFiles = [...byFile.keys()].sort();
const now = new Date().toISOString();
const lines: string[] = [];
lines.push("# Console-Call Migration Inventory");
lines.push("");
lines.push(`> Generated by \`scripts/observability/scan-console.ts\` on ${now}.`);
lines.push("> Re-run with: \`pnpm exec tsx scripts/observability/scan-console.ts\`");
lines.push("");
lines.push(`**Total \`console.*\` hits in server-code paths: ${allHits.length}**`);
lines.push(`**Unique files: ${sortedFiles.length}**`);
lines.push("");
lines.push("Server-paths scanned:");
lines.push("");
lines.push("- `apps/api/src/**`");
lines.push("- `apps/worker/src/**`");
lines.push("- `apps/web/app/**/*.server.ts(x)` and `apps/web/app/modules/*/services/**` and `apps/web/app/utils/**`");
lines.push("- `packages/*/src/**`");
lines.push("");
lines.push("Excluded:");
lines.push("");
lines.push("- `**/*.test.ts(x)`, `**/*.spec.ts(x)`");
lines.push("- Lines after `// eslint-disable-next-line no-console`");
lines.push("- `dist/`, `build/`, `coverage/`, `node_modules/`, `.react-router/`, `generated/`");
lines.push("");
if (allHits.length === 0) {
lines.push("## All clear");
lines.push("");
lines.push("No `console.*` calls remain in server-code paths. The ESLint rule `no-console: error` enforces this going forward.");
} else {
lines.push("## Hits by file");
lines.push("");
lines.push("| File | Line | Level | Snippet |");
lines.push("|------|-----:|-------|---------|");
for (const file of sortedFiles) {
for (const h of byFile.get(file)!) {
const snippetEscaped = h.snippet.replace(/\|/g, "\\|").replace(/`/g, "\\`");
lines.push(`| \`${h.file}\` | ${h.line} | ${h.level} | \`${snippetEscaped}\` |`);
}
}
lines.push("");
lines.push("## Summary by file");
lines.push("");
lines.push("| File | Hits |");
lines.push("|------|-----:|");
for (const file of sortedFiles) {
lines.push(`| \`${file}\` | ${byFile.get(file)!.length} |`);
}
}
mkdirSync(dirname(OUTPUT), { recursive: true });
writeFileSync(OUTPUT, lines.join("\n") + "\n", "utf8");
// eslint-disable-next-line no-console
console.log(`scan-console: ${allHits.length} hit(s) across ${sortedFiles.length} file(s). Wrote ${relative(REPO_ROOT, OUTPUT)}`);
if (checkMode && allHits.length > 0) {
process.exit(1);
}
}
main();
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
#
# Smoke-test the /status and /metrics endpoints on a deployed boilerplate.
#
# Usage:
# METRICS_TOKEN=... scripts/observability/smoke-metrics.sh # uses staging defaults
# METRICS_TOKEN=... scripts/observability/smoke-metrics.sh https://api.example.com https://worker.example.com:9100 https://app.example.com
#
# Exits non-zero on the first failing assertion. Designed to run from CI
# after deploy and from the operator's laptop.
set -euo pipefail
API_BASE="${1:-https://api.boiler.saas-ctrl.com}"
WORKER_BASE="${2:-}"
WEB_BASE="${3:-https://app.boiler.saas-ctrl.com}"
if [ -z "${METRICS_TOKEN:-}" ]; then
echo "FAIL: METRICS_TOKEN env var is required." >&2
exit 2
fi
EXIT_CODE=0
probe_status() {
local label="$1"
local url="$2"
local response status body
response=$(curl --max-time 10 --silent --show-error --write-out '\n%{http_code}' "$url" || true)
status=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$status" != "200" ] && [ "$status" != "503" ]; then
echo "FAIL: ${label} /status returned ${status} (expected 200 or 503)" >&2
echo "Body: ${body}" >&2
EXIT_CODE=1
return
fi
if ! printf '%s' "$body" | grep -q '"service":"' ; then
echo "FAIL: ${label} /status JSON missing service field" >&2
echo "Body: ${body}" >&2
EXIT_CODE=1
return
fi
echo "OK: ${label} /status (HTTP ${status})"
}
probe_metrics_unauth() {
local label="$1"
local url="$2"
local status
status=$(curl --max-time 10 --silent --output /dev/null --write-out '%{http_code}' "$url" || true)
if [ "$status" != "401" ] && [ "$status" != "404" ]; then
echo "FAIL: ${label} /metrics without token returned ${status} (expected 401 or 404)" >&2
EXIT_CODE=1
return
fi
echo "OK: ${label} /metrics without token (HTTP ${status})"
}
probe_metrics_authed() {
local label="$1"
local url="$2"
local response status body
response=$(curl --max-time 10 --silent --show-error -H "Authorization: Bearer ${METRICS_TOKEN}" \
--write-out '\n%{http_code}' "$url" || true)
status=$(printf '%s' "$response" | tail -n1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$status" != "200" ]; then
echo "FAIL: ${label} /metrics returned ${status} (expected 200)" >&2
echo "Body (first 200 chars): $(printf '%s' "$body" | head -c 200)" >&2
EXIT_CODE=1
return
fi
for expected in "http_requests_total" "process_cpu_seconds_total"; do
if ! printf '%s' "$body" | grep -q "$expected" ; then
echo "FAIL: ${label} /metrics missing metric '${expected}'" >&2
EXIT_CODE=1
return
fi
done
echo "OK: ${label} /metrics (HTTP 200, expected metric names found)"
}
# API
probe_status "api" "${API_BASE}/status"
probe_metrics_unauth "api" "${API_BASE}/metrics"
probe_metrics_authed "api" "${API_BASE}/metrics"
# Web (uses /api/status + /api/metrics resource routes)
probe_status "web" "${WEB_BASE}/api/status"
probe_metrics_unauth "web" "${WEB_BASE}/api/metrics"
probe_metrics_authed "web" "${WEB_BASE}/api/metrics"
# Worker — only probed when the caller passed a URL (worker admin server is
# typically on a private network without TLS).
if [ -n "$WORKER_BASE" ]; then
probe_status "worker" "${WORKER_BASE}/status"
probe_metrics_unauth "worker" "${WORKER_BASE}/metrics"
probe_metrics_authed "worker" "${WORKER_BASE}/metrics"
else
echo "SKIP: worker /status + /metrics (pass a URL as the second positional arg to enable)"
fi
if [ $EXIT_CODE -eq 0 ]; then
echo "All smoke checks passed."
fi
exit $EXIT_CODE
@@ -0,0 +1,161 @@
#!/usr/bin/env tsx
/**
* OpenTelemetry OTLP HTTP smoke test.
*
* Sends a synthetic trace (one parent + two children) directly to the
* configured OTLP HTTP endpoint via `fetch` (no SDK), so an operator can
* verify the end-to-end pipeline without spinning up the full api/worker
* stack:
* - DNS + TLS to the OTLP endpoint
* - Gateway / Relay routes `POST /v1/traces` (or `/<path>/v1/traces`)
* - Sentry/Tempo/Jaeger persists the trace
*
* Run from the repo root:
* OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com/v1/traces \
* pnpm exec tsx scripts/observability/smoke-otel.ts
*
* Exits 0 on 2xx response, 1 on non-2xx, 2 on missing endpoint config.
*
* @see docs/improvement-plan/02-observability/opentelemetry.md (Step 10)
*/
import { randomBytes } from "node:crypto";
/* eslint-disable no-console */
interface SmokeSpan {
traceId: string;
spanId: string;
parentSpanId: string | undefined;
name: string;
kind: number;
startTimeUnixNano: string;
endTimeUnixNano: string;
attributes: Array<{ key: string; value: { stringValue?: string; intValue?: string } }>;
}
function hex(bytes: number): string {
return randomBytes(bytes).toString("hex");
}
function buildSpans(): SmokeSpan[] {
const traceId = hex(16);
const parentSpanId = hex(8);
const apiSpanId = hex(8);
const workerSpanId = hex(8);
const start = Date.now() - 1000;
return [
{
traceId,
spanId: parentSpanId,
parentSpanId: undefined,
name: "smoke-otel.root",
kind: 1, // INTERNAL
startTimeUnixNano: String(BigInt(start) * 1_000_000n),
endTimeUnixNano: String(BigInt(start + 800) * 1_000_000n),
attributes: [
{ key: "service.name", value: { stringValue: "boilerplate-smoke" } },
{ key: "smoke.kind", value: { stringValue: "root" } },
],
},
{
traceId,
spanId: apiSpanId,
parentSpanId,
name: "smoke-otel.api",
kind: 2, // SERVER
startTimeUnixNano: String(BigInt(start + 100) * 1_000_000n),
endTimeUnixNano: String(BigInt(start + 500) * 1_000_000n),
attributes: [
{ key: "service.name", value: { stringValue: "boilerplate-smoke-api" } },
{ key: "http.method", value: { stringValue: "GET" } },
{ key: "http.target", value: { stringValue: "/healthz" } },
],
},
{
traceId,
spanId: workerSpanId,
parentSpanId: apiSpanId,
name: "smoke-otel.worker",
kind: 5, // CONSUMER
startTimeUnixNano: String(BigInt(start + 250) * 1_000_000n),
endTimeUnixNano: String(BigInt(start + 600) * 1_000_000n),
attributes: [
{ key: "service.name", value: { stringValue: "boilerplate-smoke-worker" } },
{ key: "messaging.system", value: { stringValue: "bullmq" } },
{ key: "messaging.destination", value: { stringValue: "smoke.queue" } },
],
},
];
}
function parseHeaders(raw: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!raw) return out;
for (const pair of raw.split(",")) {
const eq = pair.indexOf("=");
if (eq < 0) continue;
const key = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (key) out[key] = value;
}
return out;
}
async function main(): Promise<void> {
const rawEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
if (!rawEndpoint) {
console.error("[smoke-otel] OTEL_EXPORTER_OTLP_ENDPOINT is not set; nothing to do.");
process.exit(2);
}
// Normalise: callers commonly pass the base endpoint without the `/v1/traces`
// path because the SDK appends it automatically. Replicate that here.
let url = rawEndpoint;
if (!/\/v1\/traces\/?$/.test(url)) {
url = url.replace(/\/$/, "") + "/v1/traces";
}
const spans = buildSpans();
const traceId = spans[0]?.traceId ?? "(none)";
console.log(`[smoke-otel] POST ${url} — trace_id=${traceId}, spans=${spans.length}`);
const payload = {
resourceSpans: [
{
resource: {
attributes: [
{ key: "service.name", value: { stringValue: "boilerplate-smoke" } },
{ key: "service.namespace", value: { stringValue: "toolctrl" } },
],
},
scopeSpans: [
{
scope: { name: "scripts/observability/smoke-otel.ts" },
spans,
},
],
},
],
};
const headers: Record<string, string> = { "content-type": "application/json", ...parseHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS) };
let response: Response;
try {
response = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
} catch (cause) {
console.error(`[smoke-otel] fetch failed: ${(cause as Error)?.message ?? String(cause)}`);
process.exit(1);
}
const text = await response.text();
if (!response.ok) {
console.error(`[smoke-otel] non-2xx response: ${response.status} ${response.statusText}\n${text.slice(0, 800)}`);
process.exit(1);
}
console.log(`[smoke-otel] ok: ${response.status}; trace_id=${traceId}`);
console.log(`[smoke-otel] body: ${text.slice(0, 400)}`);
}
void main();
@@ -0,0 +1,109 @@
import { describe, expect, test } from "vitest";
import { openApiDocument } from "../apps/api/src/openapi.js";
import { renderOpenApiSecurityGuardResult, validateOpenApiSecurity, type OpenApiDocument } from "./openapi-guard.js";
function cloneDocument(): OpenApiDocument {
return structuredClone(openApiDocument) as OpenApiDocument;
}
function operation(document: OpenApiDocument, path: string, method: string) {
const pathItem = document.paths?.[path];
if (!pathItem) throw new Error(`Missing fixture path ${path}`);
const candidate = pathItem[method];
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
throw new Error(`Missing fixture operation ${method.toUpperCase()} ${path}`);
}
return candidate as Record<string, unknown>;
}
describe("openapi security guard", () => {
test("passes the current OpenAPI document without mutating it", () => {
const document = cloneDocument();
const before = JSON.stringify(document);
const result = validateOpenApiSecurity(document);
expect(result).toEqual({ ok: true, violations: [] });
expect(JSON.stringify(document)).toEqual(before);
expect(renderOpenApiSecurityGuardResult(result)).toMatch(/^PASS openapi-security/);
});
test("fails when an operation references an undeclared security scheme", () => {
const document = cloneDocument();
operation(document, "/v1/session/current-user", "get").security = [{ missingScheme: [] }];
const result = validateOpenApiSecurity(document);
expect(result.ok).toBe(false);
expect(result.violations).toContain("GET /v1/session/current-user getCurrentUserSession: references undeclared security scheme 'missingScheme'");
});
test("fails when a protected operation omits non-empty security", () => {
const document = cloneDocument();
delete operation(document, "/v1/session/current-user", "get").security;
const result = validateOpenApiSecurity(document);
expect(result.ok).toBe(false);
expect(result.violations).toContain("GET /v1/session/current-user getCurrentUserSession: protected operation must declare non-empty security");
});
test("fails when the document has no operation surface", () => {
const result = validateOpenApiSecurity({ components: { securitySchemes: { bearerAuth: {}, apiKeyHeader: {} } }, paths: {} });
expect(result.ok).toBe(false);
expect(result.violations).toContain("OpenAPI document must define at least one path");
expect(result.violations).toContain("OpenAPI document must define at least one operation");
});
test("fails when required security schemes are missing", () => {
const document = cloneDocument();
document.components = { securitySchemes: {} };
const result = validateOpenApiSecurity(document);
expect(result.ok).toBe(false);
expect(result.violations).toContain("OpenAPI document must declare security scheme 'bearerAuth'");
expect(result.violations).toContain("OpenAPI document must declare security scheme 'apiKeyHeader'");
});
test("enforces public and optional-anonymous allowlist behavior", () => {
const publicDocument = cloneDocument();
operation(publicDocument, "/healthz", "get").security = [{ bearerAuth: [] }];
expect(validateOpenApiSecurity(publicDocument).violations).toContain("GET /healthz getHealthz: public allowlisted operation must omit security or declare security: []");
const optionalDocument = cloneDocument();
expect(validateOpenApiSecurity(optionalDocument).ok).toBe(true);
const anonymousProtectedDocument = cloneDocument();
operation(anonymousProtectedDocument, "/v1/session/current-user", "get").security = [{ bearerAuth: [] }, {}];
expect(validateOpenApiSecurity(anonymousProtectedDocument).violations).toContain(
"GET /v1/session/current-user getCurrentUserSession: anonymous security requirement is only allowed for optional-auth allowlisted operations",
);
});
test("fails when scoped apiKeyHeader operations omit permission metadata", () => {
const document = cloneDocument();
delete operation(document, "/v1/entities/{entity}/rows", "get")["x-api-key-permissions"];
const result = validateOpenApiSecurity(document);
expect(result.ok).toBe(false);
expect(result.violations).toContain("GET /v1/entities/{entity}/rows listLegacyEntityRows: scoped apiKeyHeader operation must include x-api-key-permissions");
});
test("fails when service-token metadata operations include scoped permission metadata", () => {
const document = cloneDocument();
operation(document, "/v1/entities/{entity}", "get")["x-api-key-permissions"] = { credential: "apiKeyHeader" };
const result = validateOpenApiSecurity(document);
expect(result.ok).toBe(false);
expect(result.violations).toContain("GET /v1/entities/{entity} getLegacyEntityByName: service-token metadata operation must not include x-api-key-permissions");
});
});
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { fileURLToPath } from "node:url";
import { openApiDocument } from "../apps/api/src/openapi.js";
const httpMethods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]);
const publicOperationAllowlist = new Set([
"GET /healthz",
"GET /v1/health",
"GET /readyz",
"GET /status",
"GET /v1/docs",
"GET /v1/openapi.json",
"POST /v1/auth/login",
]);
const optionalAnonymousAllowlist = new Set(["POST /v1/api-keys/validate"]);
export type OpenApiDocument = {
components?: {
securitySchemes?: Record<string, unknown>;
};
paths?: Record<string, Record<string, unknown>>;
};
export type GuardResult = {
ok: boolean;
violations: string[];
};
type OperationObject = {
operationId?: unknown;
security?: unknown;
"x-api-key-permissions"?: unknown;
};
type OperationEntry = {
method: string;
path: string;
operation: OperationObject;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function operationContext({ method, path, operation }: OperationEntry) {
const operationId = typeof operation.operationId === "string" && operation.operationId.length > 0 ? operation.operationId : "<missing operationId>";
return `${method.toUpperCase()} ${path} ${operationId}`;
}
function listOperations(document: OpenApiDocument): OperationEntry[] {
const operations: OperationEntry[] = [];
const paths = isRecord(document.paths) ? document.paths : {};
for (const [path, pathItem] of Object.entries(paths)) {
if (!isRecord(pathItem)) continue;
for (const [method, operation] of Object.entries(pathItem)) {
const normalizedMethod = method.toLowerCase();
if (!httpMethods.has(normalizedMethod) || !isRecord(operation)) continue;
operations.push({ method: normalizedMethod, path, operation: operation as OperationObject });
}
}
return operations;
}
function securityRequirementSchemeNames(security: unknown): string[] {
if (!Array.isArray(security)) return [];
return security.flatMap((requirement) => {
if (!isRecord(requirement)) return [];
return Object.keys(requirement);
});
}
function hasEmptySecurityRequirement(security: unknown) {
return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0);
}
function hasNonEmptySecurityRequirement(security: unknown) {
return Array.isArray(security) && security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length > 0);
}
function usesScheme(operation: OperationObject, schemeName: string) {
return securityRequirementSchemeNames(operation.security).includes(schemeName);
}
function isScopedApiKeyPath(path: string) {
return /^\/v1\/entities\/\{entity\}\/rows(?:\/\{rowId\})?$/.test(path) || /^\/v1\/relationships(?:\/\{id\})?$/.test(path);
}
function isServiceTokenOnlyMetadataPath(path: string) {
return /^\/v1\/entities\/\{entity\}$/.test(path);
}
export function validateOpenApiSecurity(document: OpenApiDocument = openApiDocument): GuardResult {
const declaredSchemes = new Set(Object.keys(document.components?.securitySchemes ?? {}));
const violations: string[] = [];
const operations = listOperations(document);
if (!isRecord(document.paths) || Object.keys(document.paths).length === 0) {
violations.push("OpenAPI document must define at least one path");
}
if (operations.length === 0) {
violations.push("OpenAPI document must define at least one operation");
}
for (const schemeName of ["bearerAuth", "apiKeyHeader"]) {
if (!declaredSchemes.has(schemeName)) {
violations.push(`OpenAPI document must declare security scheme '${schemeName}'`);
}
}
for (const entry of operations) {
const context = operationContext(entry);
const allowlistKey = `${entry.method.toUpperCase()} ${entry.path}`;
const { operation } = entry;
const schemeNames = securityRequirementSchemeNames(operation.security);
for (const schemeName of schemeNames) {
if (!declaredSchemes.has(schemeName)) {
violations.push(`${context}: references undeclared security scheme '${schemeName}'`);
}
}
if (publicOperationAllowlist.has(allowlistKey)) {
if (operation.security !== undefined && (!Array.isArray(operation.security) || operation.security.length !== 0)) {
violations.push(`${context}: public allowlisted operation must omit security or declare security: []`);
}
} else {
if (!hasNonEmptySecurityRequirement(operation.security)) {
violations.push(`${context}: protected operation must declare non-empty security`);
}
if (hasEmptySecurityRequirement(operation.security) && !optionalAnonymousAllowlist.has(allowlistKey)) {
violations.push(`${context}: anonymous security requirement is only allowed for optional-auth allowlisted operations`);
}
}
if (isScopedApiKeyPath(entry.path) && usesScheme(operation, "apiKeyHeader") && operation["x-api-key-permissions"] === undefined) {
violations.push(`${context}: scoped apiKeyHeader operation must include x-api-key-permissions`);
}
if (isServiceTokenOnlyMetadataPath(entry.path) && operation["x-api-key-permissions"] !== undefined) {
violations.push(`${context}: service-token metadata operation must not include x-api-key-permissions`);
}
}
return { ok: violations.length === 0, violations };
}
export function renderOpenApiSecurityGuardResult(result: GuardResult) {
if (result.ok) return "PASS openapi-security: all operation security contracts are valid";
return ["FAIL openapi-security:", ...result.violations.map((violation) => `- ${violation}`)].join("\n");
}
function main() {
const result = validateOpenApiSecurity(openApiDocument);
const output = renderOpenApiSecurityGuardResult(result);
if (result.ok) {
console.log(output);
return;
}
console.error(output);
process.exitCode = 1;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}
@@ -0,0 +1,82 @@
#!/usr/bin/env node
// Upserts a sticky PR comment on Gitea identified by the marker
// `<!-- bundle-budget-report -->`. Reads bundle-comment.md from the
// workflow's cwd and updates an existing comment if found, otherwise
// creates a new one.
//
// Environment:
// GITEA_TOKEN required. PAT with repository write scope.
// GITEA_HOST default: git.tool-ctrl.de
// REPO "owner/repo" (provided by Gitea workflow context).
// PR_NUMBER PR number to post on.
// COMMENT_PATH optional, default: bundle-comment.md
import fs from "node:fs";
const MARKER = "<!-- bundle-budget-report -->";
async function main() {
const token = process.env.GITEA_TOKEN;
const host = process.env.GITEA_HOST || "git.tool-ctrl.de";
const repo = process.env.REPO;
const prNumber = process.env.PR_NUMBER;
const commentPath = process.env.COMMENT_PATH || "bundle-comment.md";
if (!token || !repo || !prNumber) {
console.error("post-bundle-budget-gitea-comment: missing GITEA_TOKEN / REPO / PR_NUMBER env vars");
process.exit(0); // soft-skip: workflow stays green
}
if (!fs.existsSync(commentPath)) {
console.error(`post-bundle-budget-gitea-comment: ${commentPath} not found, skipping`);
process.exit(0);
}
const body = fs.readFileSync(commentPath, "utf8");
const apiBase = `https://${host}/api/v1/repos/${repo}/issues/${prNumber}/comments`;
const headers = {
Authorization: `token ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
// 1) list comments, find existing by marker
const listRes = await fetch(apiBase, { headers });
if (!listRes.ok) {
console.error(`Gitea list-comments failed: ${listRes.status}`);
process.exit(0);
}
const comments = await listRes.json();
const existing = Array.isArray(comments) ? comments.find((c) => typeof c.body === "string" && c.body.includes(MARKER)) : null;
if (existing) {
const updateUrl = `https://${host}/api/v1/repos/${repo}/issues/comments/${existing.id}`;
const res = await fetch(updateUrl, {
method: "PATCH",
headers,
body: JSON.stringify({ body }),
});
if (!res.ok) {
console.error(`Gitea update-comment failed: ${res.status}`);
process.exit(0);
}
console.log(`Updated bundle-budget comment #${existing.id}`);
} else {
const res = await fetch(apiBase, {
method: "POST",
headers,
body: JSON.stringify({ body }),
});
if (!res.ok) {
console.error(`Gitea create-comment failed: ${res.status}`);
process.exit(0);
}
const created = await res.json();
console.log(`Created bundle-budget comment #${created.id}`);
}
}
main().catch((err) => {
console.error(err);
// Soft-fail: PR comment is informational, never fail the workflow on transient API errors.
process.exit(0);
});
@@ -0,0 +1,78 @@
#!/usr/bin/env node
// Upserts a sticky PR comment on Gitea identified by COMMENT_MARKER
// (default: `<!-- coverage-report -->`). Reads coverage-comment.md from the
// workflow's cwd and updates an existing comment if found, otherwise
// creates a new one.
//
// Environment:
// GITEA_TOKEN required. PAT with repository write scope.
// GITEA_HOST default: git.tool-ctrl.de
// REPO "owner/repo" (provided by Gitea workflow context).
// PR_NUMBER PR number to post on.
// COMMENT_PATH optional, default: coverage-comment.md
// COMMENT_MARKER optional, default: <!-- coverage-report -->
import fs from "node:fs";
async function main() {
const token = process.env.GITEA_TOKEN;
const host = process.env.GITEA_HOST || "git.tool-ctrl.de";
const repo = process.env.REPO;
const prNumber = process.env.PR_NUMBER;
const commentPath = process.env.COMMENT_PATH || "coverage-comment.md";
const marker = process.env.COMMENT_MARKER || "<!-- coverage-report -->";
if (!token || !repo || !prNumber) {
console.error("post-coverage-gitea-comment: missing GITEA_TOKEN / REPO / PR_NUMBER env vars");
process.exit(0); // soft-skip: workflow stays green
}
if (!fs.existsSync(commentPath)) {
console.error(`post-coverage-gitea-comment: ${commentPath} not found, skipping`);
process.exit(0);
}
const body = fs.readFileSync(commentPath, "utf8");
const apiBase = `https://${host}/api/v1/repos/${repo}/issues/${prNumber}/comments`;
const headers = {
Authorization: `token ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
};
const listRes = await fetch(apiBase, { headers });
if (!listRes.ok) {
console.error(`Gitea list-comments failed: ${listRes.status}`);
process.exit(0);
}
const comments = await listRes.json();
const existing = Array.isArray(comments) ? comments.find((c) => typeof c.body === "string" && c.body.includes(marker)) : null;
if (existing) {
const updateRes = await fetch(`https://${host}/api/v1/repos/${repo}/issues/comments/${existing.id}`, {
method: "PATCH",
headers,
body: JSON.stringify({ body }),
});
if (!updateRes.ok) {
console.error(`Gitea update-comment failed: ${updateRes.status}`);
process.exit(0);
}
console.log(`coverage comment updated (id=${existing.id})`);
} else {
const createRes = await fetch(apiBase, {
method: "POST",
headers,
body: JSON.stringify({ body }),
});
if (!createRes.ok) {
console.error(`Gitea create-comment failed: ${createRes.status}`);
process.exit(0);
}
console.log("coverage comment created");
}
}
main().catch((error) => {
console.error(error);
process.exit(0);
});
@@ -0,0 +1,137 @@
#!/usr/bin/env node
// Renders the markdown body for the Bundle-Budget PR comment.
//
// Inputs:
// --current <path> JSON produced by `verify-bundle-size.mjs --json`
// --baseline <path> Optional baseline JSON from main branch (for Δ column).
// Missing/unreadable → Δ column shows "—".
// --out <path> Write rendered markdown here (default: bundle-comment.md)
//
// Output marker: the comment starts with `<!-- bundle-budget-report -->` so
// downstream upsert steps can identify and replace it without duplication.
import fs from "node:fs";
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--current") out.current = argv[++i];
else if (arg === "--baseline") out.baseline = argv[++i];
else if (arg === "--out") out.out = argv[++i];
}
return out;
}
export function readReport(filePath) {
if (!filePath) return null;
try {
if (!fs.existsSync(filePath)) return null;
const raw = fs.readFileSync(filePath, "utf8");
if (!raw.trim()) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
const STATUS_BADGE = {
ok: "[OK]",
warning: "[WARN]",
exceeds: "[EXCEED]",
unbudgeted: "[?]",
};
const KIND_LABEL = {
route: "route",
vendor: "vendor",
};
export function formatKb(bytes) {
if (typeof bytes !== "number") return "—";
return `${(bytes / 1024).toFixed(1)} KB`;
}
export function formatDelta(currentBytes, baselineBytes) {
if (typeof baselineBytes !== "number") return "—";
const diff = currentBytes - baselineBytes;
const sign = diff >= 0 ? "+" : "";
return `${sign}${(diff / 1024).toFixed(1)} KB`;
}
export function indexBaseline(report) {
const map = new Map();
if (!report?.entries) return map;
for (const e of report.entries) {
map.set(e.key, e);
}
return map;
}
export function buildMarkdown(current, baseline) {
const lines = [];
lines.push("<!-- bundle-budget-report -->");
lines.push("## Bundle Budget Report");
lines.push("");
if (!current || current.skipped) {
lines.push("> No bundle artifacts measured (web build skipped or empty).");
return lines.join("\n");
}
const totals = current.totals ?? { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 };
lines.push(
`**Summary:** ok=${totals.ok} - warning=${totals.warning} - exceeds=${totals.exceeds} - unbudgeted=${totals.unbudgeted}`,
);
lines.push("");
if (totals.exceeds > 0) {
lines.push(
"> Some routes exceed their configured limit. This does **not** block the PR, but please check whether the growth is intentional.",
);
lines.push("");
}
const baselineIndex = indexBaseline(baseline);
const entries = [...(current.entries ?? [])];
// sort: exceeds → warning → unbudgeted → ok, then biggest first
const rank = { exceeds: 0, warning: 1, unbudgeted: 2, ok: 3 };
entries.sort((a, b) => {
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status];
return b.gzipSize - a.gzipSize;
});
lines.push("| Status | Kind | Route / Chunk | Size (gzip) | Limit | Δ from main |");
lines.push("|---|---|---|---|---|---|");
for (const e of entries) {
const status = STATUS_BADGE[e.status] ?? e.status;
const kind = KIND_LABEL[e.kind] ?? e.kind;
const limit = typeof e.limitKb === "number" ? `${e.limitKb} KB` : "default";
const base = baselineIndex.get(e.key);
const delta = formatDelta(e.gzipSize, base?.gzipSize);
lines.push(`| ${status} | ${kind} | \`${e.key}\` | ${formatKb(e.gzipSize)} | ${limit} | ${delta} |`);
}
lines.push("");
lines.push("_Sizes are gzip-compressed. Budgets configured in `scripts/bundle-budgets.json`. Docs: `docs/architecture/bundle-budgets.md`._");
return lines.join("\n");
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.current) {
console.error("Usage: render-bundle-budget-comment.mjs --current <path> [--baseline <path>] [--out <path>]");
process.exit(2);
}
const current = readReport(args.current);
const baseline = readReport(args.baseline);
const md = buildMarkdown(current, baseline);
const outPath = args.out ?? "bundle-comment.md";
fs.writeFileSync(outPath, `${md}\n`, "utf8");
// Also surface on stdout for log visibility.
process.stdout.write(`${md}\n`);
}
const isMain = import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
main().catch((err) => {
console.error(err);
process.exit(1);
});
}
@@ -0,0 +1,108 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { buildMarkdown, formatDelta, formatKb, indexBaseline, readReport } from "./render-bundle-budget-comment.mjs";
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bundle-budget-render-"));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("formatKb", () => {
it("formats bytes as KB to 1 decimal", () => {
expect(formatKb(2048)).toBe("2.0 KB");
expect(formatKb(1536)).toBe("1.5 KB");
});
it("returns dash for non-numeric", () => {
expect(formatKb(undefined)).toBe("—");
});
});
describe("formatDelta", () => {
it("returns signed KB delta vs baseline", () => {
expect(formatDelta(3000, 1000)).toBe("+2.0 KB");
expect(formatDelta(1000, 3000)).toBe("-2.0 KB");
expect(formatDelta(1000, 1000)).toBe("+0.0 KB");
});
it("returns dash when no baseline", () => {
expect(formatDelta(1000, undefined)).toBe("—");
});
});
describe("readReport", () => {
it("returns null for missing path", () => {
expect(readReport(path.join(tmpDir, "missing.json"))).toBeNull();
});
it("returns null for empty file", () => {
const p = path.join(tmpDir, "empty.json");
fs.writeFileSync(p, "");
expect(readReport(p)).toBeNull();
});
it("parses valid json", () => {
const p = path.join(tmpDir, "ok.json");
fs.writeFileSync(p, JSON.stringify({ a: 1 }));
expect(readReport(p)).toEqual({ a: 1 });
});
});
describe("indexBaseline", () => {
it("indexes by key", () => {
const idx = indexBaseline({
entries: [
{ key: "auth/login", gzipSize: 1000 },
{ key: "vendor-react", gzipSize: 200 },
],
});
expect(idx.get("auth/login")?.gzipSize).toBe(1000);
expect(idx.get("vendor-react")?.gzipSize).toBe(200);
});
it("returns empty map for null", () => {
expect(indexBaseline(null).size).toBe(0);
});
});
describe("buildMarkdown", () => {
it("includes marker for sticky-comment upsert", () => {
const md = buildMarkdown({ skipped: true });
expect(md).toContain("<!-- bundle-budget-report -->");
});
it("renders summary + per-asset table sorted by severity", () => {
const current = {
skipped: false,
totals: { ok: 1, warning: 1, exceeds: 1, unbudgeted: 0 },
entries: [
{ key: "auth/login", kind: "route", gzipSize: 50 * 1024, status: "ok", limitKb: 100 },
{ key: "app/$tenant/crm", kind: "route", gzipSize: 270 * 1024, status: "exceeds", limitKb: 250 },
{ key: "vendor-react", kind: "vendor", gzipSize: 140 * 1024, status: "warning", limitKb: 150 },
],
};
const baseline = {
entries: [
{ key: "auth/login", gzipSize: 52 * 1024 },
{ key: "app/$tenant/crm", gzipSize: 260 * 1024 },
],
};
const md = buildMarkdown(current, baseline);
expect(md).toContain("exceeds=1");
expect(md).toContain("`app/$tenant/crm`");
expect(md).toContain("[EXCEED]");
// exceeds row appears before warning row
const exceedIdx = md.indexOf("[EXCEED]");
const warnIdx = md.indexOf("[WARN]");
expect(exceedIdx).toBeLessThan(warnIdx);
// delta is rendered for matched key (current 270KB vs baseline 260KB)
expect(md).toContain("+10.0 KB");
// unmatched vendor-react has dash delta
expect(md).toContain("| —");
});
it("handles a skipped current report gracefully", () => {
const md = buildMarkdown({ skipped: true });
expect(md).toContain("No bundle artifacts measured");
});
});
@@ -0,0 +1,223 @@
#!/usr/bin/env node
import fs from "node:fs";
const DEFAULT_LOGIN_URL = "https://boiler.saas-ctrl.com/login";
const DEFAULT_COOKIE_NAME = "RJ_session";
function fail(message) {
console.error(`FAIL lighthouse cookie rotation: ${message}`);
process.exit(1);
}
function requiredEnv(name) {
const value = process.env[name];
if (typeof value !== "string" || value.trim().length === 0) {
fail(`missing required env var ${name}`);
}
return value;
}
function optionalEnv(name) {
const value = process.env[name];
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function assertSafeEnvValue(name, value) {
if (/[\r\n]/.test(value)) {
fail(`${name} must not contain newline characters`);
}
}
function splitSetCookieHeader(headerValue) {
if (!headerValue) return [];
const cookies = [];
let start = 0;
for (let index = 0; index < headerValue.length; index += 1) {
if (headerValue[index] !== ",") continue;
const rest = headerValue.slice(index + 1);
if (/^\s*[^=;,\s]+=/.test(rest)) {
cookies.push(headerValue.slice(start, index).trim());
start = index + 1;
}
}
cookies.push(headerValue.slice(start).trim());
return cookies.filter(Boolean);
}
function getSetCookieHeaders(headers) {
if (typeof headers.getSetCookie === "function") {
return headers.getSetCookie();
}
return splitSetCookieHeader(headers.get("set-cookie"));
}
function parseCookiePair(setCookieHeader) {
const [pair] = setCookieHeader.split(";");
const separator = pair.indexOf("=");
if (separator <= 0) return null;
return {
name: pair.slice(0, separator).trim(),
value: pair.slice(separator + 1).trim(),
};
}
function collectCookies(headers, jar) {
for (const header of getSetCookieHeaders(headers)) {
const parsed = parseCookiePair(header);
if (parsed) {
jar.set(parsed.name, parsed.value);
}
}
}
function cookieHeader(jar) {
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
}
function decodeHtmlAttribute(value) {
return value
.replaceAll("&quot;", "\"")
.replaceAll("&#34;", "\"")
.replaceAll("&#x22;", "\"")
.replaceAll("&apos;", "'")
.replaceAll("&#39;", "'")
.replaceAll("&#x27;", "'")
.replaceAll("&amp;", "&");
}
function parseAttributes(tag) {
const attributes = new Map();
const attributePattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
let match;
while ((match = attributePattern.exec(tag))) {
attributes.set(match[1].toLowerCase(), decodeHtmlAttribute(match[2] ?? match[3] ?? match[4] ?? ""));
}
return attributes;
}
function extractCsrfToken(html) {
const inputPattern = /<input\b[^>]*>/gi;
let match;
while ((match = inputPattern.exec(html))) {
const attributes = parseAttributes(match[0]);
if (attributes.get("name") === "csrf" && attributes.has("value")) {
return attributes.get("value");
}
}
return null;
}
function appendGitHubEnv(values) {
const githubEnv = process.env.GITHUB_ENV;
if (!githubEnv) return;
const lines = [];
for (const [name, value] of Object.entries(values)) {
if (typeof value !== "string" || value.length === 0) continue;
assertSafeEnvValue(name, value);
lines.push(`${name}=${value}`);
}
if (lines.length > 0) {
fs.appendFileSync(githubEnv, `${lines.join("\n")}\n`, { mode: 0o600 });
}
}
function writeCookieOutput(cookieValue) {
const outputPath = process.env.LIGHTHOUSE_SESSION_COOKIE_OUTPUT;
if (!outputPath) return;
assertSafeEnvValue("LIGHTHOUSE_SESSION_COOKIE_OUTPUT", outputPath);
fs.writeFileSync(outputPath, `${cookieValue}\n`, { mode: 0o600 });
try {
fs.chmodSync(outputPath, 0o600);
} catch {
// Best-effort on filesystems that do not support chmod.
}
}
async function main() {
const loginUrl = process.env.LIGHTHOUSE_LOGIN_URL || DEFAULT_LOGIN_URL;
const email = optionalEnv("LIGHTHOUSE_LOGIN_EMAIL");
const password = optionalEnv("LIGHTHOUSE_LOGIN_PASSWORD");
const cookieName = process.env.LIGHTHOUSE_SESSION_COOKIE_NAME || DEFAULT_COOKIE_NAME;
const cookieDomain = process.env.LIGHTHOUSE_COOKIE_DOMAIN || new URL(loginUrl).hostname;
const fallbackCookie = optionalEnv("LIGHTHOUSE_SESSION_COOKIE");
if (!email || !password) {
if (fallbackCookie) {
appendGitHubEnv({
LIGHTHOUSE_SESSION_COOKIE: fallbackCookie,
LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName,
LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain,
});
writeCookieOutput(fallbackCookie);
console.log(`Using pre-provisioned Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`);
return;
}
requiredEnv(!email ? "LIGHTHOUSE_LOGIN_EMAIL" : "LIGHTHOUSE_LOGIN_PASSWORD");
}
const jar = new Map();
const getResponse = await fetch(loginUrl, {
redirect: "manual",
headers: {
accept: "text/html,application/xhtml+xml",
},
});
if (!getResponse.ok) {
fail(`GET ${new URL(loginUrl).origin}/login returned HTTP ${getResponse.status}`);
}
collectCookies(getResponse.headers, jar);
const html = await getResponse.text();
const csrf = extractCsrfToken(html);
if (!csrf) {
fail("login page did not contain an input[name=csrf] token");
}
const form = new URLSearchParams({
action: "login",
email,
password,
redirectTo: "/",
csrf,
});
const postResponse = await fetch(loginUrl, {
method: "POST",
redirect: "manual",
headers: {
accept: "text/html,application/xhtml+xml",
"content-type": "application/x-www-form-urlencoded",
cookie: cookieHeader(jar),
"x-csrf-token": csrf,
},
body: form,
});
collectCookies(postResponse.headers, jar);
if (postResponse.status < 200 || postResponse.status >= 400) {
fail(`POST ${new URL(loginUrl).origin}/login returned HTTP ${postResponse.status}`);
}
const sessionCookie = jar.get(cookieName);
if (!sessionCookie) {
fail(`login did not return the ${cookieName} session cookie`);
}
appendGitHubEnv({
LIGHTHOUSE_SESSION_COOKIE: sessionCookie,
LIGHTHOUSE_SESSION_COOKIE_NAME: cookieName,
LIGHTHOUSE_COOKIE_DOMAIN: cookieDomain,
});
writeCookieOutput(sessionCookie);
console.log(`Minted Lighthouse session cookie ${cookieName} for ${new URL(loginUrl).origin}.`);
}
await main();
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, it } from "vitest";
const scriptPath = path.resolve("scripts/rotate-lighthouse-session-cookie.mjs");
const fixtures = [];
function createTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lighthouse-cookie-"));
fixtures.push(dir);
return dir;
}
function spawnScript(env) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [scriptPath], {
env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("close", (status) => {
resolve({ status, stdout, stderr });
});
});
}
function startLoginServer() {
const requests = [];
const assertionErrors = [];
const server = http.createServer((req, res) => {
requests.push(req);
if (req.method === "GET" && req.url === "/login") {
res.writeHead(200, {
"content-type": "text/html",
"set-cookie": "RJ_session=seed-cookie; Path=/; HttpOnly",
});
res.end('<form><input value="csrf-token-123" name="csrf" type="hidden"></form>');
return;
}
if (req.method === "POST" && req.url === "/login") {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
const params = new URLSearchParams(body);
assert.equal(req.headers["x-csrf-token"], "csrf-token-123");
assert.equal(req.headers.cookie, "RJ_session=seed-cookie");
assert.equal(params.get("action"), "login");
assert.equal(params.get("csrf"), "csrf-token-123");
assert.equal(params.get("email"), "lighthouse@example.test");
assert.equal(params.get("password"), "super-secret-password");
res.writeHead(302, {
location: "/",
"set-cookie": "RJ_session=fresh-cookie; Path=/; HttpOnly; Secure",
});
res.end();
} catch (error) {
assertionErrors.push(error);
res.writeHead(500);
res.end("assertion failed");
}
});
return;
}
res.writeHead(404);
res.end("not found");
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
resolve({
url: `http://127.0.0.1:${server.address().port}/login`,
assertionErrors,
requests,
close: () => new Promise((closeResolve) => server.close(closeResolve)),
});
});
});
}
afterEach(() => {
while (fixtures.length > 0) {
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
}
});
describe("rotate-lighthouse-session-cookie", () => {
it("logs in with CSRF protection and writes the fresh cookie to GITHUB_ENV", async () => {
const server = await startLoginServer();
const tempDir = createTempDir();
const githubEnv = path.join(tempDir, "github-env");
try {
const result = await spawnScript({
...process.env,
GITHUB_ENV: githubEnv,
LIGHTHOUSE_LOGIN_URL: server.url,
LIGHTHOUSE_LOGIN_EMAIL: "lighthouse@example.test",
LIGHTHOUSE_LOGIN_PASSWORD: "super-secret-password",
LIGHTHOUSE_SESSION_COOKIE_NAME: "RJ_session",
LIGHTHOUSE_COOKIE_DOMAIN: "boiler.saas-ctrl.com",
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Minted Lighthouse session cookie RJ_session/);
assert.doesNotMatch(result.stdout, /fresh-cookie|super-secret-password/);
assert.doesNotMatch(result.stderr, /fresh-cookie|super-secret-password/);
const envFile = fs.readFileSync(githubEnv, "utf8");
assert.match(envFile, /^LIGHTHOUSE_SESSION_COOKIE=fresh-cookie$/m);
assert.match(envFile, /^LIGHTHOUSE_SESSION_COOKIE_NAME=RJ_session$/m);
assert.match(envFile, /^LIGHTHOUSE_COOKIE_DOMAIN=boiler\.saas-ctrl\.com$/m);
assert.equal(server.requests.length, 2);
assert.deepEqual(server.assertionErrors, []);
} finally {
await server.close();
}
});
it("fails fast when credentials are missing without printing secret material", () => {
const env = { ...process.env };
delete env.LIGHTHOUSE_SESSION_COOKIE;
env.LIGHTHOUSE_LOGIN_EMAIL = "";
env.LIGHTHOUSE_LOGIN_PASSWORD = "";
const result = spawnSync(process.execPath, [scriptPath], {
encoding: "utf8",
env,
});
assert.equal(result.status, 1);
assert.match(result.stderr, /missing required env var LIGHTHOUSE_LOGIN_EMAIL/);
assert.doesNotMatch(result.stderr, /password/i);
});
it("falls back to a pre-provisioned cookie while login credentials are being rolled out", () => {
const tempDir = createTempDir();
const githubEnv = path.join(tempDir, "github-env");
const result = spawnSync(process.execPath, [scriptPath], {
encoding: "utf8",
env: {
...process.env,
GITHUB_ENV: githubEnv,
LIGHTHOUSE_LOGIN_EMAIL: "",
LIGHTHOUSE_LOGIN_PASSWORD: "",
LIGHTHOUSE_SESSION_COOKIE: "existing-cookie",
LIGHTHOUSE_SESSION_COOKIE_NAME: "RJ_session",
},
});
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Using pre-provisioned Lighthouse session cookie RJ_session/);
assert.doesNotMatch(result.stdout, /existing-cookie/);
assert.match(fs.readFileSync(githubEnv, "utf8"), /^LIGHTHOUSE_SESSION_COOKIE=existing-cookie$/m);
});
});
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
const result = spawnSync("npx", ["--yes", "@lhci/cli@0.13.x", "autorun"], {
stdio: "inherit",
});
if (result.error?.code === "ENOENT") {
console.log("SKIP lhci not installable in this environment");
process.exit(0);
}
process.exit(result.status ?? 0);
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env node
// Safe runner for the k6 load-test suite.
//
// SKIPs cleanly (exit 0) when the k6 binary is missing or the target URL is
// unreachable, so this script is safe to wire into CI lanes that may run on
// machines without k6 installed. Only fails (non-zero exit) when k6 itself
// reports a threshold or runtime failure.
import path from "node:path";
import url from "node:url";
import { spawnSync } from "node:child_process";
const DEFAULT_TARGET_URL = "http://localhost:3000";
const DEFAULT_SCENARIO_PATH = "tests/load/scenarios.js";
const REACHABILITY_TIMEOUT_SECONDS = 3;
/**
* Test whether the k6 binary is available on PATH.
* Returns the trimmed version string on success, null on failure.
*/
export function detectK6Binary({ spawn = spawnSync } = {}) {
const result = spawn("k6", ["version"], { encoding: "utf8" });
if (result.error || result.status !== 0) {
return null;
}
return (result.stdout || "").trim();
}
/**
* Test whether the configured target URL responds to an HTTP request.
* Uses curl with a short timeout so the runner does not hang when the
* target environment is offline. Returns true on reachable, false otherwise.
*/
export function isTargetReachable(targetUrl, { spawn = spawnSync, timeoutSeconds = REACHABILITY_TIMEOUT_SECONDS } = {}) {
const result = spawn(
"curl",
[
"-fsS",
"--max-time",
String(timeoutSeconds),
"-o",
"/dev/null",
"-w",
"%{http_code}",
targetUrl,
],
{ encoding: "utf8" },
);
if (result.error) {
return false;
}
// curl -fsS exits non-zero on HTTP >= 400, but for reachability we accept
// any successful TCP+HTTP roundtrip. Re-check by looking at the captured
// status code in stdout when curl exited non-zero.
if (result.status === 0) {
return true;
}
const code = (result.stdout || "").trim();
return /^[1-5]\d\d$/.test(code);
}
/**
* Parse argv into a configuration object.
* --target <url> override K6_TARGET_URL
* --scenario <path> override the default composite scenario script
* --skip-reachability skip the curl probe (useful when the target is
* only reachable from within a Docker network)
*/
export function parseArgs(argv) {
const config = {
targetUrl: null,
scenarioPath: null,
skipReachability: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--target" || arg === "--target-url") {
config.targetUrl = argv[index + 1] || null;
index += 1;
} else if (arg === "--scenario") {
config.scenarioPath = argv[index + 1] || null;
index += 1;
} else if (arg === "--skip-reachability") {
config.skipReachability = true;
}
}
return config;
}
/**
* Resolve the final runtime configuration from argv + env, applying defaults.
*/
export function resolveConfig({ argv = [], env = process.env } = {}) {
const parsed = parseArgs(argv);
const targetUrl = parsed.targetUrl || env.K6_TARGET_URL || DEFAULT_TARGET_URL;
const scenarioPath = parsed.scenarioPath || DEFAULT_SCENARIO_PATH;
return {
targetUrl,
scenarioPath,
skipReachability: parsed.skipReachability,
};
}
/**
* Run the load-test suite. Returns an object describing the outcome so the
* CLI wrapper can map it to an exit code.
*/
export function runLoadTests({
argv = [],
env = process.env,
spawn = spawnSync,
stdout = (line) => process.stdout.write(`${line}\n`),
stderr = (line) => process.stderr.write(`${line}\n`),
} = {}) {
const config = resolveConfig({ argv, env });
const version = detectK6Binary({ spawn });
if (!version) {
stdout(
"SKIP k6 binary not installed; download from https://k6.io/docs/getting-started/installation/",
);
return { status: "skip", reason: "binary-missing", exitCode: 0 };
}
if (!config.skipReachability && !isTargetReachable(config.targetUrl, { spawn })) {
stdout(`SKIP target ${config.targetUrl} not reachable`);
return { status: "skip", reason: "target-unreachable", exitCode: 0 };
}
stdout(`PASS k6 detected (${version}); running ${config.scenarioPath} against ${config.targetUrl}`);
const result = spawn("k6", ["run", config.scenarioPath], {
stdio: "inherit",
env: { ...env, K6_TARGET_URL: config.targetUrl },
});
if (result.error) {
stderr(`FAIL k6 run failed to start: ${result.error.message}`);
return { status: "fail", reason: "spawn-error", exitCode: 1 };
}
if (typeof result.status === "number" && result.status !== 0) {
stderr(`FAIL k6 run exited with status ${result.status}`);
return { status: "fail", reason: "non-zero-exit", exitCode: result.status };
}
return { status: "pass", reason: "ok", exitCode: 0 };
}
const isCli = process.argv[1] && url.fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const outcome = runLoadTests({ argv: process.argv.slice(2) });
process.exit(outcome.exitCode);
}
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { parseArgs, resolveConfig, runLoadTests } from "./run-load-tests.mjs";
function recordingSpawn(handlers) {
const calls = [];
function spawn(command, args, options) {
calls.push({ command, args, options });
const handler = handlers[command];
if (!handler) {
return { status: 1, stdout: "", stderr: "", error: new Error(`unexpected spawn for ${command}`) };
}
return handler({ args, options });
}
spawn.calls = calls;
return spawn;
}
function captureLines() {
const lines = [];
function write(line) {
lines.push(line);
}
write.lines = lines;
return write;
}
describe("run-load-tests", () => {
it("parses --target, --scenario, and --skip-reachability flags", () => {
const parsed = parseArgs([
"--target",
"http://staging.example.test",
"--scenario",
"tests/load/login.js",
"--skip-reachability",
]);
assert.equal(parsed.targetUrl, "http://staging.example.test");
assert.equal(parsed.scenarioPath, "tests/load/login.js");
assert.equal(parsed.skipReachability, true);
});
it("prefers --target argv over K6_TARGET_URL env var", () => {
const config = resolveConfig({
argv: ["--target", "http://argv.example.test"],
env: { K6_TARGET_URL: "http://env.example.test" },
});
assert.equal(config.targetUrl, "http://argv.example.test");
});
it("falls back to K6_TARGET_URL env var when no --target argv is given", () => {
const config = resolveConfig({
argv: [],
env: { K6_TARGET_URL: "http://env.example.test" },
});
assert.equal(config.targetUrl, "http://env.example.test");
});
it("uses the default URL when neither argv nor env provides one", () => {
const config = resolveConfig({ argv: [], env: {} });
assert.equal(config.targetUrl, "http://localhost:3000");
assert.equal(config.scenarioPath, "tests/load/scenarios.js");
assert.equal(config.skipReachability, false);
});
it("SKIPs with exit 0 when the k6 binary is missing", () => {
const spawn = recordingSpawn({
k6: () => ({ status: 127, stdout: "", stderr: "", error: new Error("ENOENT") }),
});
const stdout = captureLines();
const stderr = captureLines();
const outcome = runLoadTests({ argv: [], env: {}, spawn, stdout, stderr });
assert.equal(outcome.status, "skip");
assert.equal(outcome.reason, "binary-missing");
assert.equal(outcome.exitCode, 0);
assert.equal(stdout.lines.length, 1);
assert.match(stdout.lines[0], /^SKIP k6 binary not installed/);
assert.match(stdout.lines[0], /k6\.io\/docs\/getting-started\/installation/);
assert.equal(stderr.lines.length, 0);
// k6 was probed, curl was never reached.
assert.equal(spawn.calls.filter((call) => call.command === "curl").length, 0);
});
it("SKIPs with exit 0 when the target URL is unreachable", () => {
const spawn = recordingSpawn({
k6: () => ({ status: 0, stdout: "k6 v0.50.0", stderr: "" }),
curl: () => ({ status: 7, stdout: "", stderr: "Connection refused" }),
});
const stdout = captureLines();
const stderr = captureLines();
const outcome = runLoadTests({
argv: ["--target", "http://offline.example.test"],
env: {},
spawn,
stdout,
stderr,
});
assert.equal(outcome.status, "skip");
assert.equal(outcome.reason, "target-unreachable");
assert.equal(outcome.exitCode, 0);
assert.ok(
stdout.lines.some((line) => /^SKIP target http:\/\/offline\.example\.test not reachable$/.test(line)),
`expected SKIP line, got: ${stdout.lines.join(" | ")}`,
);
// k6 run was never invoked once the target was unreachable.
const k6RunCalls = spawn.calls.filter((call) => call.command === "k6" && call.args[0] === "run");
assert.equal(k6RunCalls.length, 0);
});
it("honours K6_TARGET_URL env override and forwards it into the k6 run env", () => {
const spawn = recordingSpawn({
k6: ({ args }) => {
if (args[0] === "version") {
return { status: 0, stdout: "k6 v0.50.0", stderr: "" };
}
// k6 run — succeed.
return { status: 0, stdout: "", stderr: "" };
},
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const stderr = captureLines();
const outcome = runLoadTests({
argv: ["--skip-reachability"],
env: { K6_TARGET_URL: "http://override.example.test" },
spawn,
stdout,
stderr,
});
assert.equal(outcome.status, "pass");
assert.equal(outcome.exitCode, 0);
const k6RunCall = spawn.calls.find((call) => call.command === "k6" && call.args[0] === "run");
assert.ok(k6RunCall, "expected k6 run to be invoked");
assert.deepEqual(k6RunCall.args, ["run", "tests/load/scenarios.js"]);
assert.equal(k6RunCall.options.env.K6_TARGET_URL, "http://override.example.test");
assert.ok(
stdout.lines.some((line) => line.includes("http://override.example.test")),
`expected PASS line to mention override URL, got: ${stdout.lines.join(" | ")}`,
);
});
});
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env node
/**
* SKIP-safe wrapper around `playwright test` for the e2e a11y gate.
*
* Skips (exit 0) when any of the following are true:
* - Playwright runtime is not installed (`npx --no-install playwright --version` fails)
* - Target URL (E2E_BASE_URL or http://localhost:3000) is not reachable
*
* Set REQUIRE_E2E_A11Y=1 in staging/CI lanes where missing browser runtime or
* an unreachable target must fail instead of skipping. Set REQUIRE_AUTH_E2E=1
* when authenticated browser specs must not silently skip. Set
* REQUIRE_E2E_READINESS=1 for staging/readiness lanes where authenticated
* dashboard, governance settings, subscription, portal management, admin email
* sequences, plugin settings, workflow builder, workflow smoke inputs, and
* read-only plugin credentials must be present before Playwright starts. Set
* REQUIRE_E2E_MUTATING_READINESS=1 only
* for disposable readiness lanes where registration/account/plugin mutation
* opt-ins must also be present. Set E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1 only in
* lanes with an externally acquired checkout success path; it requires the
* readiness lane, mobile browser matrix, E2E_TENANT_DASHBOARD_PATH, and
* E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH instead of letting that smoke skip.
*
* Otherwise runs the default Playwright suite under `tests/e2e/`. Set
* E2E_BROWSER_MATRIX=1 to add mobile Chromium/WebKit projects from
* playwright.config.ts.
*/
import { spawnSync } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
export const DEFAULT_TARGET_URL = "http://localhost:3000";
export function resolveTargetUrl({ env = process.env } = {}) {
const configuredUrl = env.E2E_BASE_URL?.trim();
return configuredUrl || DEFAULT_TARGET_URL;
}
export function hasPlaywrightRuntime({ spawn = spawnSync } = {}) {
const result = spawn("npx", ["--no-install", "playwright", "--version"], {
stdio: "ignore",
timeout: 30_000,
});
return result.status === 0;
}
export function isUrlReachable(url, { spawn = spawnSync, allowServerErrors = true } = {}) {
const result = spawn(
"curl",
["-fsS", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", url],
{ encoding: "utf8", timeout: 10_000 },
);
if (result.status !== 0) return false;
const code = parseInt((result.stdout || "").trim(), 10);
// In local SKIP-safe mode, any HTTP response means the app is reachable and
// Playwright can decide which pages should pass or skip. Strict readiness
// lanes fail early on 5xx so a broken staging target cannot look like a pass.
if (!Number.isFinite(code) || code <= 0) return false;
return allowServerErrors || code < 500;
}
function defaultStdout(message) {
process.stdout.write(`[e2e-a11y] ${message}\n`);
}
function requiredFailure({ stdout, reason, message }) {
stdout(`FAIL: ${message}`);
return { status: "fail", reason, exitCode: 1 };
}
export function hasRequiredAuthEnv({ env = process.env } = {}) {
return Boolean(env.E2E_TENANT_EMAIL && env.E2E_TENANT_PASSWORD);
}
export function missingReadinessEnv({ env = process.env } = {}) {
const required = [
"E2E_TENANT_EMAIL",
"E2E_TENANT_PASSWORD",
"E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION",
"E2E_TENANT_DASHBOARD_PATH",
"E2E_TENANT_SETTINGS_ACCOUNT_PATH",
"E2E_TENANT_SETTINGS_PROFILE_PATH",
"E2E_TENANT_SUBSCRIPTION_PATH",
"E2E_TENANT_MEMBERS_PATH",
"E2E_TENANT_ROLES_AND_PERMISSIONS_PATH",
"E2E_TENANT_NOTIFICATIONS_PATH",
"E2E_TENANT_LOGS_PATH",
"E2E_TENANT_LOG_EVENTS_PATH",
"E2E_TENANT_AUDIT_EXPORT_PATH",
"E2E_TENANT_PRIVACY_PATH",
"E2E_TENANT_CHATBOT_PATH",
"E2E_TENANT_AI_USAGE_PATH",
"E2E_TENANT_API_PATH",
"E2E_TENANT_API_KEYS_PATH",
"E2E_TENANT_API_DOCS_PATH",
"E2E_TENANT_API_LOGS_PATH",
"E2E_TENANT_POLICIES_PATH",
"E2E_TENANT_PORTAL_ANALYTICS_PATH",
"E2E_TENANT_PORTAL_SETTINGS_PATH",
"E2E_ADMIN_EMAIL_SEQUENCES_PATH",
"E2E_PLUGIN_SETTINGS_PATH",
"E2E_PLUGIN_READONLY_EMAIL",
"E2E_PLUGIN_READONLY_PASSWORD",
"E2E_WORKFLOW_ENGINE_PATH",
"E2E_WORKFLOW_BUILDER_PATH",
"E2E_WORKFLOW_RUN_API_PATH",
"E2E_WORKFLOW_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WEBHOOK_BUILDER_PATH",
"E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WAITING_BUILDER_PATH",
"E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WAITING_RUN_API_PATH",
"E2E_WORKFLOW_WAITING_EXECUTIONS_PATH",
"E2E_WORKFLOW_WAITING_EXECUTION_ID",
"PLUGIN_BINARY_BASE_PATH",
];
const missing = required.filter((key) => !env[key]?.trim());
const registrationVerification = env.E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION;
if (registrationVerification && registrationVerification !== "0" && registrationVerification !== "1") {
missing.push("E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION=0|1");
}
return missing;
}
export function missingReadinessModeEnv({ env = process.env } = {}) {
const requiredModes = {
E2E_A11Y_STRICT: "1",
E2E_BROWSER_MATRIX: "1",
E2E_ALLOW_WORKFLOW_RUN: "1",
};
return Object.entries(requiredModes)
.filter(([key, expected]) => env[key] !== expected)
.map(([key, expected]) => `${key}=${expected}`);
}
export function missingReadinessMutatingOptInEnv({ env = process.env } = {}) {
const requiredOptIns = {
E2E_ALLOW_REGISTRATION_CREATE: "1",
E2E_ALLOW_ACCOUNT_CREATE: "1",
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1",
E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: "1",
E2E_ALLOW_PLUGIN_SETTINGS_WRITE: "1",
};
return Object.entries(requiredOptIns)
.filter(([key, expected]) => env[key] !== expected)
.map(([key, expected]) => `${key}=${expected}`);
}
export function missingNoTenantAccountCreateEnv({ env = process.env } = {}) {
if (env.E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE !== "1") return [];
return ["E2E_NO_TENANT_EMAIL", "E2E_NO_TENANT_PASSWORD"].filter((key) => !env[key]?.trim());
}
export function missingSubscriptionSuccessReadinessEnv({ env = process.env } = {}) {
if (env.E2E_REQUIRE_SUBSCRIPTION_SUCCESS !== "1") return [];
const missing = ["E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH", "E2E_TENANT_DASHBOARD_PATH"].filter((key) => !env[key]?.trim());
if (env.REQUIRE_E2E_READINESS !== "1") {
missing.push("REQUIRE_E2E_READINESS=1");
}
if (env.E2E_BROWSER_MATRIX !== "1") {
missing.push("E2E_BROWSER_MATRIX=1");
}
return missing;
}
export function missingWorkflowWebhookRunReadinessEnv({ env = process.env } = {}) {
if (env.E2E_ALLOW_WORKFLOW_WEBHOOK_RUN !== "1") return [];
return [
"E2E_TENANT_EMAIL",
"E2E_TENANT_PASSWORD",
"E2E_WORKFLOW_WEBHOOK_BUILDER_PATH",
"E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WEBHOOK_TARGET_URL",
].filter((key) => !env[key]?.trim());
}
export function missingWorkflowOutboxDrainReadinessEnv({ env = process.env } = {}) {
if (env.E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN !== "1") return [];
const missing = ["E2E_TENANT_EMAIL", "E2E_TENANT_PASSWORD", "E2E_WORKFLOW_RUN_MANUAL_PATH"].filter((key) => !env[key]?.trim());
const expectedStatus = env.E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS?.trim().toLowerCase();
if (expectedStatus && expectedStatus !== "success" && expectedStatus !== "error") {
missing.push("E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS=success|error");
}
return missing;
}
export function runPlaywrightA11y({
env = process.env,
spawn = spawnSync,
stdout = defaultStdout,
} = {}) {
const targetUrl = resolveTargetUrl({ env });
const requireE2eA11y = env.REQUIRE_E2E_A11Y === "1";
const requireAuthE2e = env.REQUIRE_AUTH_E2E === "1";
const requireReadinessE2e = env.REQUIRE_E2E_READINESS === "1";
const requireMutatingReadinessE2e = env.REQUIRE_E2E_MUTATING_READINESS === "1";
if (env.SKIP_E2E_A11Y === "1") {
const requiredLanes = [
["REQUIRE_E2E_A11Y", requireE2eA11y],
["REQUIRE_AUTH_E2E", requireAuthE2e],
["REQUIRE_E2E_READINESS", requireReadinessE2e],
["REQUIRE_E2E_MUTATING_READINESS", requireMutatingReadinessE2e],
["E2E_REQUIRE_SUBSCRIPTION_SUCCESS", env.E2E_REQUIRE_SUBSCRIPTION_SUCCESS === "1"],
]
.filter(([, enabled]) => enabled)
.map(([key]) => `${key}=1`);
if (requiredLanes.length > 0) {
return requiredFailure({
stdout,
reason: "explicit-skip-in-required-lane",
message: `SKIP_E2E_A11Y=1 cannot be used with required E2E lanes: ${requiredLanes.join(", ")}.`,
});
}
stdout("SKIP_E2E_A11Y=1 set — skipping.");
return { status: "skip", reason: "explicit-skip", exitCode: 0 };
}
const missingWorkflowWebhookRun = missingWorkflowWebhookRunReadinessEnv({ env });
if (missingWorkflowWebhookRun.length > 0) {
return requiredFailure({
stdout,
reason: "workflow-webhook-run-readiness-env-missing",
message: `E2E workflow webhook run readiness env missing: ${missingWorkflowWebhookRun.join(", ")} while E2E_ALLOW_WORKFLOW_WEBHOOK_RUN=1.`,
});
}
const missingWorkflowOutboxDrain = missingWorkflowOutboxDrainReadinessEnv({ env });
if (missingWorkflowOutboxDrain.length > 0) {
return requiredFailure({
stdout,
reason: "workflow-outbox-drain-readiness-env-missing",
message: `E2E workflow outbox-drain readiness env missing: ${missingWorkflowOutboxDrain.join(", ")} while E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1.`,
});
}
const missingSubscriptionSuccess = missingSubscriptionSuccessReadinessEnv({ env });
if (missingSubscriptionSuccess.length > 0) {
return requiredFailure({
stdout,
reason: "subscription-success-readiness-env-missing",
message: `E2E subscription success readiness env missing: ${missingSubscriptionSuccess.join(", ")} while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1.`,
});
}
const missingNoTenantAccountCreate = missingNoTenantAccountCreateEnv({ env });
if (missingNoTenantAccountCreate.length > 0) {
return requiredFailure({
stdout,
reason: "no-tenant-account-create-env-missing",
message: `E2E no-tenant account creation env missing: ${missingNoTenantAccountCreate.join(", ")} while E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1.`,
});
}
if (!hasPlaywrightRuntime({ spawn })) {
if (requireE2eA11y) {
return requiredFailure({
stdout,
reason: "runtime-missing",
message: "Playwright runtime not installed while REQUIRE_E2E_A11Y=1.",
});
}
stdout("SKIP: Playwright runtime not installed (run `pnpm install` and `pnpm exec playwright install chromium`).");
return { status: "skip", reason: "runtime-missing", exitCode: 0 };
}
if (!isUrlReachable(targetUrl, { spawn, allowServerErrors: !requireE2eA11y })) {
if (requireE2eA11y) {
return requiredFailure({
stdout,
reason: "target-unreachable",
message: `Target URL ${targetUrl} not reachable while REQUIRE_E2E_A11Y=1.`,
});
}
stdout(`SKIP: Target URL ${targetUrl} not reachable.`);
stdout("Hint: start the app with `pnpm dev` or set E2E_BASE_URL=<staging-url>.");
return { status: "skip", reason: "target-unreachable", exitCode: 0 };
}
if (requireAuthE2e && !hasRequiredAuthEnv({ env })) {
return requiredFailure({
stdout,
reason: "auth-env-missing",
message: "E2E_TENANT_EMAIL / E2E_TENANT_PASSWORD not set while REQUIRE_AUTH_E2E=1.",
});
}
if (requireReadinessE2e) {
const missing = missingReadinessEnv({ env });
if (missing.length > 0) {
return requiredFailure({
stdout,
reason: "readiness-env-missing",
message: `E2E readiness env missing: ${missing.join(", ")}.`,
});
}
const missingModes = missingReadinessModeEnv({ env });
if (missingModes.length > 0) {
return requiredFailure({
stdout,
reason: "readiness-mode-missing",
message: `E2E readiness mode missing: ${missingModes.join(", ")}.`,
});
}
const missingMutatingOptIns = requireMutatingReadinessE2e ? missingReadinessMutatingOptInEnv({ env }) : [];
if (missingMutatingOptIns.length > 0) {
return requiredFailure({
stdout,
reason: "readiness-mutating-opt-in-missing",
message: `E2E readiness mutating opt-in missing: ${missingMutatingOptIns.join(", ")}.`,
});
}
}
stdout(`Target ${targetUrl} reachable — running Playwright a11y suite.`);
const result = spawn(
"pnpm",
["exec", "playwright", "test", "--config=playwright.config.ts"],
{ stdio: "inherit" },
);
if (result.error) {
return { status: "fail", reason: "spawn-error", exitCode: 1 };
}
if (typeof result.status === "number" && result.status !== 0) {
return { status: "fail", reason: "non-zero-exit", exitCode: result.status };
}
return { status: "pass", reason: "ok", exitCode: 0 };
}
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isCli) {
const outcome = runPlaywrightA11y();
process.exit(outcome.exitCode);
}
@@ -0,0 +1,993 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { describe, it } from "vitest";
import {
hasRequiredAuthEnv,
isUrlReachable,
missingReadinessEnv,
missingReadinessModeEnv,
missingReadinessMutatingOptInEnv,
missingNoTenantAccountCreateEnv,
missingSubscriptionSuccessReadinessEnv,
missingWorkflowOutboxDrainReadinessEnv,
missingWorkflowWebhookRunReadinessEnv,
resolveTargetUrl,
runPlaywrightA11y,
} from "./run-playwright-a11y.mjs";
function recordingSpawn(handlers) {
const calls = [];
function spawn(command, args, options) {
calls.push({ command, args, options });
const handler = handlers[command];
if (!handler) {
return { status: 1, stdout: "", stderr: "", error: new Error(`unexpected spawn for ${command}`) };
}
return handler({ args, options });
}
spawn.calls = calls;
return spawn;
}
function captureLines() {
const lines = [];
function write(line) {
lines.push(line);
}
write.lines = lines;
return write;
}
function completeReadinessEnv(overrides = {}) {
return {
E2E_BASE_URL: "https://staging.example.test",
E2E_TENANT_EMAIL: "user@example.test",
E2E_TENANT_PASSWORD: "secret",
E2E_NO_TENANT_EMAIL: "no-tenant@example.test",
E2E_NO_TENANT_PASSWORD: "no-tenant-secret",
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0",
E2E_TENANT_DASHBOARD_PATH: "/app/acme/dashboard",
E2E_TENANT_SETTINGS_ACCOUNT_PATH: "/app/acme/settings/account",
E2E_TENANT_SETTINGS_PROFILE_PATH: "/app/acme/settings/profile",
E2E_TENANT_SUBSCRIPTION_PATH: "/app/acme/settings/subscription",
E2E_TENANT_MEMBERS_PATH: "/app/acme/settings/members",
E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: "/app/acme/settings/roles-and-permissions",
E2E_TENANT_NOTIFICATIONS_PATH: "/app/acme/settings/notifications",
E2E_TENANT_LOGS_PATH: "/app/acme/settings/logs",
E2E_TENANT_LOG_EVENTS_PATH: "/app/acme/settings/logs/events",
E2E_TENANT_AUDIT_EXPORT_PATH: "/app/acme/settings/audit-export",
E2E_TENANT_PRIVACY_PATH: "/app/acme/settings/privacy",
E2E_TENANT_CHATBOT_PATH: "/app/acme/settings/chatbot",
E2E_TENANT_AI_USAGE_PATH: "/app/acme/settings/ai-usage",
E2E_TENANT_API_PATH: "/app/acme/settings/api",
E2E_TENANT_API_KEYS_PATH: "/app/acme/settings/api/keys",
E2E_TENANT_API_DOCS_PATH: "/app/acme/settings/api/docs",
E2E_TENANT_API_LOGS_PATH: "/app/acme/settings/api/logs",
E2E_TENANT_POLICIES_PATH: "/app/acme/settings/policies",
E2E_TENANT_PORTAL_ANALYTICS_PATH: "/app/acme/portals/readiness/analytics",
E2E_TENANT_PORTAL_SETTINGS_PATH: "/app/acme/portals/readiness/settings",
E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/admin/email-sequences",
E2E_PLUGIN_SETTINGS_PATH: "/app/acme/settings/plugins/plugin-1",
E2E_PLUGIN_READONLY_EMAIL: "plugin-readonly@example.test",
E2E_PLUGIN_READONLY_PASSWORD: "readonly-secret",
E2E_WORKFLOW_ENGINE_PATH: "/app/acme/workflows",
E2E_WORKFLOW_BUILDER_PATH: "/app/acme/workflows/wf-1/builder",
E2E_WORKFLOW_RUN_API_PATH: "/app/acme/workflows/wf-1/run/api",
E2E_WORKFLOW_RUN_MANUAL_PATH: "/app/acme/workflows/wf-1/run",
E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: "/app/acme/workflows/wf-webhook/builder",
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: "/app/acme/workflows/wf-webhook/run",
E2E_WORKFLOW_WAITING_BUILDER_PATH: "/app/acme/workflows/wf-waiting/builder",
E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: "/app/acme/workflows/wf-waiting/run",
E2E_WORKFLOW_WAITING_RUN_API_PATH: "/app/acme/workflows/wf-waiting/run/api",
E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: "/app/acme/workflows/wf-waiting/executions",
E2E_WORKFLOW_WAITING_EXECUTION_ID: "wfe-waiting-1",
PLUGIN_BINARY_BASE_PATH: "/tmp/e2e-plugins",
E2E_A11Y_STRICT: "1",
E2E_BROWSER_MATRIX: "1",
E2E_ALLOW_WORKFLOW_RUN: "1",
E2E_ALLOW_REGISTRATION_CREATE: "1",
E2E_ALLOW_ACCOUNT_CREATE: "1",
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1",
E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: "1",
E2E_ALLOW_PLUGIN_SETTINGS_WRITE: "1",
REQUIRE_E2E_READINESS: "1",
...overrides,
};
}
describe("run-playwright-a11y", () => {
it("uses E2E_BASE_URL when provided", () => {
assert.equal(resolveTargetUrl({ env: { E2E_BASE_URL: "https://staging.example.test" } }), "https://staging.example.test");
});
it("falls back to localhost when E2E_BASE_URL is missing", () => {
assert.equal(resolveTargetUrl({ env: {} }), "http://localhost:3000");
});
it("falls back to localhost when E2E_BASE_URL is blank", () => {
assert.equal(resolveTargetUrl({ env: { E2E_BASE_URL: "" } }), "http://localhost:3000");
assert.equal(resolveTargetUrl({ env: { E2E_BASE_URL: " " } }), "http://localhost:3000");
});
it("trims E2E_BASE_URL before probing the target", () => {
assert.equal(resolveTargetUrl({ env: { E2E_BASE_URL: " https://staging.example.test " } }), "https://staging.example.test");
});
it("SKIPs with exit 0 when SKIP_E2E_A11Y=1 is set", () => {
const spawn = recordingSpawn({});
const stdout = captureLines();
const outcome = runPlaywrightA11y({ env: { SKIP_E2E_A11Y: "1" }, spawn, stdout });
assert.deepEqual(outcome, { status: "skip", reason: "explicit-skip", exitCode: 0 });
assert.deepEqual(stdout.lines, ["SKIP_E2E_A11Y=1 set — skipping."]);
assert.equal(spawn.calls.length, 0);
});
it("fails instead of honoring SKIP_E2E_A11Y in required readiness lanes", () => {
const spawn = recordingSpawn({});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: {
SKIP_E2E_A11Y: "1",
REQUIRE_E2E_A11Y: "1",
REQUIRE_AUTH_E2E: "1",
REQUIRE_E2E_READINESS: "1",
E2E_REQUIRE_SUBSCRIPTION_SUCCESS: "1",
},
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "explicit-skip-in-required-lane", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: SKIP_E2E_A11Y=1 cannot be used with required E2E lanes: REQUIRE_E2E_A11Y=1, REQUIRE_AUTH_E2E=1, REQUIRE_E2E_READINESS=1, E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1.",
]);
assert.equal(spawn.calls.length, 0);
});
it("SKIPs with exit 0 when the Playwright runtime is missing", () => {
const spawn = recordingSpawn({
npx: () => ({ status: 1, stdout: "", stderr: "", error: new Error("ENOENT") }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({ env: {}, spawn, stdout });
assert.deepEqual(outcome, { status: "skip", reason: "runtime-missing", exitCode: 0 });
assert.match(stdout.lines[0], /^SKIP: Playwright runtime not installed/);
assert.equal(spawn.calls.filter((call) => call.command === "curl").length, 0);
});
it("fails instead of skipping missing runtime when REQUIRE_E2E_A11Y=1", () => {
const spawn = recordingSpawn({
npx: () => ({ status: 1, stdout: "", stderr: "", error: new Error("ENOENT") }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({ env: { REQUIRE_E2E_A11Y: "1" }, spawn, stdout });
assert.deepEqual(outcome, { status: "fail", reason: "runtime-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, ["FAIL: Playwright runtime not installed while REQUIRE_E2E_A11Y=1."]);
});
it("SKIPs with exit 0 when the target URL is unreachable", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
assert.deepEqual(args, ["--no-install", "playwright", "--version"]);
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
},
curl: () => ({ status: 7, stdout: "", stderr: "Connection refused" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: { E2E_BASE_URL: "http://offline.example.test" },
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "skip", reason: "target-unreachable", exitCode: 0 });
assert.ok(stdout.lines.some((line) => line === "SKIP: Target URL http://offline.example.test not reachable."));
assert.equal(spawn.calls.filter((call) => call.command === "npx" && call.args[0] === "playwright").length, 0);
});
it("fails instead of skipping unreachable targets when REQUIRE_E2E_A11Y=1", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
assert.deepEqual(args, ["--no-install", "playwright", "--version"]);
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
},
curl: () => ({ status: 7, stdout: "", stderr: "Connection refused" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: { E2E_BASE_URL: "http://offline.example.test", REQUIRE_E2E_A11Y: "1" },
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "target-unreachable", exitCode: 1 });
assert.deepEqual(stdout.lines, ["FAIL: Target URL http://offline.example.test not reachable while REQUIRE_E2E_A11Y=1."]);
});
it("fails before running Playwright when authenticated E2E credentials are required but missing", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: { E2E_BASE_URL: "https://staging.example.test", REQUIRE_AUTH_E2E: "1" },
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "auth-env-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, ["FAIL: E2E_TENANT_EMAIL / E2E_TENANT_PASSWORD not set while REQUIRE_AUTH_E2E=1."]);
assert.equal(spawn.calls.filter((call) => call.command === "npx" && call.args[0] === "playwright").length, 0);
});
it("detects authenticated E2E credentials", () => {
assert.equal(hasRequiredAuthEnv({ env: {} }), false);
assert.equal(hasRequiredAuthEnv({ env: { E2E_TENANT_EMAIL: "user@example.test" } }), false);
assert.equal(hasRequiredAuthEnv({ env: { E2E_TENANT_EMAIL: "user@example.test", E2E_TENANT_PASSWORD: "secret" } }), true);
});
it("fails before running Playwright when readiness E2E inputs are incomplete", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: {
E2E_BASE_URL: "https://staging.example.test",
E2E_TENANT_EMAIL: "user@example.test",
E2E_TENANT_PASSWORD: "secret",
REQUIRE_E2E_READINESS: "1",
},
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "readiness-env-missing", exitCode: 1 });
assert.match(stdout.lines[0], /E2E readiness env missing:/);
assert.match(stdout.lines[0], /E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION/);
assert.match(stdout.lines[0], /E2E_TENANT_DASHBOARD_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_SETTINGS_ACCOUNT_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_SETTINGS_PROFILE_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_SUBSCRIPTION_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_MEMBERS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_ROLES_AND_PERMISSIONS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_NOTIFICATIONS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_LOGS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_LOG_EVENTS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_AUDIT_EXPORT_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_PRIVACY_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_CHATBOT_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_AI_USAGE_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_API_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_API_KEYS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_API_DOCS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_API_LOGS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_POLICIES_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_PORTAL_ANALYTICS_PATH/);
assert.match(stdout.lines[0], /E2E_TENANT_PORTAL_SETTINGS_PATH/);
assert.match(stdout.lines[0], /E2E_PLUGIN_SETTINGS_PATH/);
assert.match(stdout.lines[0], /E2E_PLUGIN_READONLY_EMAIL/);
assert.match(stdout.lines[0], /E2E_PLUGIN_READONLY_PASSWORD/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_ENGINE_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_BUILDER_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_RUN_API_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_RUN_MANUAL_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WEBHOOK_BUILDER_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WAITING_BUILDER_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WAITING_RUN_API_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WAITING_EXECUTIONS_PATH/);
assert.match(stdout.lines[0], /E2E_WORKFLOW_WAITING_EXECUTION_ID/);
assert.match(stdout.lines[0], /PLUGIN_BINARY_BASE_PATH/);
assert.doesNotMatch(stdout.lines[0], /E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH/);
assert.doesNotMatch(stdout.lines[0], /E2E_ALLOW_WORKFLOW_RUN=1/);
assert.doesNotMatch(stdout.lines[0], /E2E_ALLOW_REGISTRATION_CREATE=1/);
assert.equal(spawn.calls.filter((call) => call.command === "npx" && call.args[0] === "playwright").length, 0);
});
it("accepts the complete readiness E2E env contract", () => {
assert.deepEqual(
missingReadinessEnv({
env: {
E2E_TENANT_EMAIL: "user@example.test",
E2E_TENANT_PASSWORD: "secret",
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0",
E2E_TENANT_DASHBOARD_PATH: "/app/acme/dashboard",
E2E_TENANT_SETTINGS_ACCOUNT_PATH: "/app/acme/settings/account",
E2E_TENANT_SETTINGS_PROFILE_PATH: "/app/acme/settings/profile",
E2E_TENANT_SUBSCRIPTION_PATH: "/app/acme/settings/subscription",
E2E_TENANT_MEMBERS_PATH: "/app/acme/settings/members",
E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: "/app/acme/settings/roles-and-permissions",
E2E_TENANT_NOTIFICATIONS_PATH: "/app/acme/settings/notifications",
E2E_TENANT_LOGS_PATH: "/app/acme/settings/logs",
E2E_TENANT_LOG_EVENTS_PATH: "/app/acme/settings/logs/events",
E2E_TENANT_AUDIT_EXPORT_PATH: "/app/acme/settings/audit-export",
E2E_TENANT_PRIVACY_PATH: "/app/acme/settings/privacy",
E2E_TENANT_CHATBOT_PATH: "/app/acme/settings/chatbot",
E2E_TENANT_AI_USAGE_PATH: "/app/acme/settings/ai-usage",
E2E_TENANT_API_PATH: "/app/acme/settings/api",
E2E_TENANT_API_KEYS_PATH: "/app/acme/settings/api/keys",
E2E_TENANT_API_DOCS_PATH: "/app/acme/settings/api/docs",
E2E_TENANT_API_LOGS_PATH: "/app/acme/settings/api/logs",
E2E_TENANT_POLICIES_PATH: "/app/acme/settings/policies",
E2E_TENANT_PORTAL_ANALYTICS_PATH: "/app/acme/portals/readiness/analytics",
E2E_TENANT_PORTAL_SETTINGS_PATH: "/app/acme/portals/readiness/settings",
E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/admin/email-sequences",
E2E_PLUGIN_SETTINGS_PATH: "/app/acme/settings/plugins/plugin-1",
E2E_PLUGIN_READONLY_EMAIL: "plugin-readonly@example.test",
E2E_PLUGIN_READONLY_PASSWORD: "readonly-secret",
E2E_WORKFLOW_ENGINE_PATH: "/app/acme/workflows",
E2E_WORKFLOW_BUILDER_PATH: "/app/acme/workflows/wf-1/builder",
E2E_WORKFLOW_RUN_API_PATH: "/app/acme/workflows/wf-1/run/api",
E2E_WORKFLOW_RUN_MANUAL_PATH: "/app/acme/workflows/wf-1/run",
E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: "/app/acme/workflows/wf-webhook/builder",
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: "/app/acme/workflows/wf-webhook/run",
E2E_WORKFLOW_WAITING_BUILDER_PATH: "/app/acme/workflows/wf-waiting/builder",
E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: "/app/acme/workflows/wf-waiting/run",
E2E_WORKFLOW_WAITING_RUN_API_PATH: "/app/acme/workflows/wf-waiting/run/api",
E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: "/app/acme/workflows/wf-waiting/executions",
E2E_WORKFLOW_WAITING_EXECUTION_ID: "wfe-waiting-1",
PLUGIN_BINARY_BASE_PATH: "/tmp/e2e-plugins",
},
}),
[]
);
});
it("rejects whitespace-only readiness values before Playwright starts", () => {
assert.deepEqual(
missingReadinessEnv({
env: completeReadinessEnv({
E2E_TENANT_DASHBOARD_PATH: " ",
E2E_PLUGIN_SETTINGS_PATH: "\n\t",
E2E_WORKFLOW_RUN_MANUAL_PATH: "",
}),
}),
["E2E_TENANT_DASHBOARD_PATH", "E2E_PLUGIN_SETTINGS_PATH", "E2E_WORKFLOW_RUN_MANUAL_PATH"]
);
});
it("keeps subscription success optional for normal readiness lanes", () => {
const env = completeReadinessEnv();
assert.deepEqual(missingReadinessEnv({ env }), []);
assert.deepEqual(missingSubscriptionSuccessReadinessEnv({ env }), []);
});
it("requires readiness and mobile dashboard follow-through env when subscription success strict mode is enabled", () => {
assert.deepEqual(
missingSubscriptionSuccessReadinessEnv({
env: { E2E_REQUIRE_SUBSCRIPTION_SUCCESS: "1" },
}),
["E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH", "E2E_TENANT_DASHBOARD_PATH", "REQUIRE_E2E_READINESS=1", "E2E_BROWSER_MATRIX=1"]
);
assert.deepEqual(
missingSubscriptionSuccessReadinessEnv({
env: completeReadinessEnv({
E2E_REQUIRE_SUBSCRIPTION_SUCCESS: "1",
E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH: "/subscribe/acme/cs_test_123/success",
}),
}),
[]
);
});
it("does not allow subscription success strict mode outside the readiness mobile matrix", () => {
assert.deepEqual(
missingSubscriptionSuccessReadinessEnv({
env: {
E2E_REQUIRE_SUBSCRIPTION_SUCCESS: "1",
E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH: "/subscribe/acme/cs_test_123/success",
E2E_TENANT_DASHBOARD_PATH: "/app/acme/dashboard",
},
}),
["REQUIRE_E2E_READINESS=1", "E2E_BROWSER_MATRIX=1"]
);
});
it("requires strict a11y, real workflow execution, and the mobile browser matrix for readiness lanes", () => {
assert.deepEqual(
missingReadinessModeEnv({
env: {
E2E_A11Y_STRICT: "1",
},
}),
["E2E_BROWSER_MATRIX=1", "E2E_ALLOW_WORKFLOW_RUN=1"]
);
assert.deepEqual(
missingReadinessModeEnv({
env: {
E2E_A11Y_STRICT: "1",
E2E_BROWSER_MATRIX: "1",
E2E_ALLOW_WORKFLOW_RUN: "1",
},
}),
[]
);
});
it("requires core mutating opt-ins for readiness lanes", () => {
assert.deepEqual(
missingReadinessMutatingOptInEnv({
env: {
E2E_ALLOW_REGISTRATION_CREATE: "1",
E2E_ALLOW_ACCOUNT_CREATE: "1",
},
}),
[
"E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1",
"E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION=1",
"E2E_ALLOW_PLUGIN_SETTINGS_WRITE=1",
]
);
assert.deepEqual(
missingReadinessMutatingOptInEnv({
env: {
E2E_ALLOW_REGISTRATION_CREATE: "1",
E2E_ALLOW_ACCOUNT_CREATE: "1",
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1",
E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: "1",
E2E_ALLOW_PLUGIN_SETTINGS_WRITE: "1",
},
}),
[]
);
});
it("requires no-tenant credentials whenever no-tenant account creation is enabled", () => {
assert.deepEqual(missingNoTenantAccountCreateEnv({ env: {} }), []);
assert.deepEqual(
missingNoTenantAccountCreateEnv({
env: { E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1" },
}),
["E2E_NO_TENANT_EMAIL", "E2E_NO_TENANT_PASSWORD"]
);
assert.deepEqual(
missingNoTenantAccountCreateEnv({
env: {
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1",
E2E_NO_TENANT_EMAIL: "no-tenant@example.test",
E2E_NO_TENANT_PASSWORD: "NoTenantPass123!",
},
}),
[]
);
});
it("fails before running Playwright when no-tenant account creation is enabled without credentials", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: {
E2E_BASE_URL: "https://staging.example.test",
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: "1",
},
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "no-tenant-account-create-env-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: E2E no-tenant account creation env missing: E2E_NO_TENANT_EMAIL, E2E_NO_TENANT_PASSWORD while E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1.",
]);
assert.equal(spawn.calls.length, 0);
});
it("fails before running Playwright when readiness mode is not strict/mobile", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: {
E2E_BASE_URL: "https://staging.example.test",
E2E_TENANT_EMAIL: "user@example.test",
E2E_TENANT_PASSWORD: "secret",
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0",
E2E_TENANT_DASHBOARD_PATH: "/app/acme/dashboard",
E2E_TENANT_SETTINGS_ACCOUNT_PATH: "/app/acme/settings/account",
E2E_TENANT_SETTINGS_PROFILE_PATH: "/app/acme/settings/profile",
E2E_TENANT_SUBSCRIPTION_PATH: "/app/acme/settings/subscription",
E2E_TENANT_MEMBERS_PATH: "/app/acme/settings/members",
E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: "/app/acme/settings/roles-and-permissions",
E2E_TENANT_NOTIFICATIONS_PATH: "/app/acme/settings/notifications",
E2E_TENANT_LOGS_PATH: "/app/acme/settings/logs",
E2E_TENANT_LOG_EVENTS_PATH: "/app/acme/settings/logs/events",
E2E_TENANT_AUDIT_EXPORT_PATH: "/app/acme/settings/audit-export",
E2E_TENANT_PRIVACY_PATH: "/app/acme/settings/privacy",
E2E_TENANT_CHATBOT_PATH: "/app/acme/settings/chatbot",
E2E_TENANT_AI_USAGE_PATH: "/app/acme/settings/ai-usage",
E2E_TENANT_API_PATH: "/app/acme/settings/api",
E2E_TENANT_API_KEYS_PATH: "/app/acme/settings/api/keys",
E2E_TENANT_API_DOCS_PATH: "/app/acme/settings/api/docs",
E2E_TENANT_API_LOGS_PATH: "/app/acme/settings/api/logs",
E2E_TENANT_POLICIES_PATH: "/app/acme/settings/policies",
E2E_TENANT_PORTAL_ANALYTICS_PATH: "/app/acme/portals/readiness/analytics",
E2E_TENANT_PORTAL_SETTINGS_PATH: "/app/acme/portals/readiness/settings",
E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/admin/email-sequences",
E2E_PLUGIN_SETTINGS_PATH: "/app/acme/settings/plugins/plugin-1",
E2E_PLUGIN_READONLY_EMAIL: "plugin-readonly@example.test",
E2E_PLUGIN_READONLY_PASSWORD: "readonly-secret",
E2E_WORKFLOW_ENGINE_PATH: "/app/acme/workflows",
E2E_WORKFLOW_BUILDER_PATH: "/app/acme/workflows/wf-1/builder",
E2E_WORKFLOW_RUN_API_PATH: "/app/acme/workflows/wf-1/run/api",
E2E_WORKFLOW_RUN_MANUAL_PATH: "/app/acme/workflows/wf-1/run",
E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: "/app/acme/workflows/wf-webhook/builder",
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: "/app/acme/workflows/wf-webhook/run",
E2E_WORKFLOW_WAITING_BUILDER_PATH: "/app/acme/workflows/wf-waiting/builder",
E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: "/app/acme/workflows/wf-waiting/run",
E2E_WORKFLOW_WAITING_RUN_API_PATH: "/app/acme/workflows/wf-waiting/run/api",
E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: "/app/acme/workflows/wf-waiting/executions",
E2E_WORKFLOW_WAITING_EXECUTION_ID: "wfe-waiting-1",
PLUGIN_BINARY_BASE_PATH: "/tmp/e2e-plugins",
REQUIRE_E2E_READINESS: "1",
},
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "readiness-mode-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, ["FAIL: E2E readiness mode missing: E2E_A11Y_STRICT=1, E2E_BROWSER_MATRIX=1, E2E_ALLOW_WORKFLOW_RUN=1."]);
assert.equal(spawn.calls.filter((call) => call.command === "pnpm" && call.args[0] === "exec").length, 0);
});
it("does not require mutating opt-ins for normal non-mutating readiness lanes", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: completeReadinessEnv({
E2E_ALLOW_REGISTRATION_CREATE: undefined,
E2E_ALLOW_ACCOUNT_CREATE: undefined,
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: undefined,
E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: undefined,
E2E_ALLOW_PLUGIN_SETTINGS_WRITE: undefined,
}),
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "pass", reason: "ok", exitCode: 0 });
assert.equal(spawn.calls.filter((call) => call.command === "pnpm" && call.args[0] === "exec").length, 1);
});
it("fails before running Playwright when mutating readiness opt-ins are missing", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: completeReadinessEnv({
E2E_ALLOW_REGISTRATION_CREATE: undefined,
E2E_ALLOW_ACCOUNT_CREATE: undefined,
E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE: undefined,
E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION: undefined,
E2E_ALLOW_PLUGIN_SETTINGS_WRITE: undefined,
REQUIRE_E2E_MUTATING_READINESS: "1",
}),
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "readiness-mutating-opt-in-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: E2E readiness mutating opt-in missing: E2E_ALLOW_REGISTRATION_CREATE=1, E2E_ALLOW_ACCOUNT_CREATE=1, E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1, E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION=1, E2E_ALLOW_PLUGIN_SETTINGS_WRITE=1.",
]);
assert.equal(spawn.calls.filter((call) => call.command === "pnpm" && call.args[0] === "exec").length, 0);
});
it("fails before running Playwright when subscription success strict mode is missing its path", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: completeReadinessEnv({ E2E_REQUIRE_SUBSCRIPTION_SUCCESS: "1" }),
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "subscription-success-readiness-env-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: E2E subscription success readiness env missing: E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1.",
]);
assert.equal(spawn.calls.filter((call) => call.command === "pnpm" && call.args[0] === "exec").length, 0);
});
it("fails before running Playwright when real workflow webhook execution lacks its target contract", () => {
const spawn = recordingSpawn({});
const stdout = captureLines();
assert.deepEqual(
missingWorkflowWebhookRunReadinessEnv({
env: { E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "1" },
}),
[
"E2E_TENANT_EMAIL",
"E2E_TENANT_PASSWORD",
"E2E_WORKFLOW_WEBHOOK_BUILDER_PATH",
"E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WEBHOOK_TARGET_URL",
]
);
assert.deepEqual(
missingWorkflowWebhookRunReadinessEnv({
env: completeReadinessEnv({
E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "1",
E2E_WORKFLOW_WEBHOOK_TARGET_URL: "https://hooks.example.test/readiness",
}),
}),
[]
);
const outcome = runPlaywrightA11y({
env: completeReadinessEnv({
E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "1",
E2E_WORKFLOW_WEBHOOK_TARGET_URL: "",
}),
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "workflow-webhook-run-readiness-env-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: E2E workflow webhook run readiness env missing: E2E_WORKFLOW_WEBHOOK_TARGET_URL while E2E_ALLOW_WORKFLOW_WEBHOOK_RUN=1.",
]);
assert.equal(spawn.calls.length, 0);
});
it("fails before running Playwright when workflow outbox-drain readiness is misconfigured", () => {
const spawn = recordingSpawn({});
const stdout = captureLines();
assert.deepEqual(
missingWorkflowOutboxDrainReadinessEnv({
env: { E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "1", E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "done" },
}),
[
"E2E_TENANT_EMAIL",
"E2E_TENANT_PASSWORD",
"E2E_WORKFLOW_RUN_MANUAL_PATH",
"E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS=success|error",
]
);
assert.deepEqual(
missingWorkflowOutboxDrainReadinessEnv({
env: completeReadinessEnv({
E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "1",
E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "error",
}),
}),
[]
);
const outcome = runPlaywrightA11y({
env: completeReadinessEnv({
E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "1",
E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "done",
}),
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "workflow-outbox-drain-readiness-env-missing", exitCode: 1 });
assert.deepEqual(stdout.lines, [
"FAIL: E2E workflow outbox-drain readiness env missing: E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS=success|error while E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1.",
]);
assert.equal(spawn.calls.length, 0);
});
it("static contract keeps subscription success strict mode explicit", () => {
const runnerSource = readFileSync("scripts/run-playwright-a11y.mjs", "utf8");
const subscriptionSource = readFileSync("tests/e2e/subscription-smoke.spec.ts", "utf8");
const readinessDocs = readFileSync("docs/testing/e2e-readiness.md", "utf8");
assert.match(runnerSource, /E2E_REQUIRE_SUBSCRIPTION_SUCCESS/u);
assert.match(runnerSource, /E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH/u);
assert.match(subscriptionSource, /E2E_REQUIRE_SUBSCRIPTION_SUCCESS/u);
assert.match(
subscriptionSource,
/Subscription checkout success smoke requires E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH while E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1/u
);
assert.match(subscriptionSource, /follows checkout success return into the tenant dashboard on mobile when strict mode is enabled/u);
assert.match(subscriptionSource, /await expectTenantDashboard\(page\)/u);
assert.match(readinessDocs, /Normal\s+readiness runs stay non-mutating and do not require\s+`E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH`/u);
assert.match(readinessDocs, /`E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1`/u);
});
it("requires plugin settings and workflow fixtures separately from mutating opt-ins", () => {
assert.deepEqual(
missingReadinessEnv({
env: {
E2E_TENANT_EMAIL: "user@example.test",
E2E_TENANT_PASSWORD: "secret",
E2E_TENANT_DASHBOARD_PATH: "/app/acme/dashboard",
E2E_TENANT_SUBSCRIPTION_PATH: "/app/acme/settings/subscription",
E2E_WORKFLOW_RUN_MANUAL_PATH: "/app/acme/workflows/wf-1/run",
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: "/app/acme/workflows/wf-webhook/run",
PLUGIN_BINARY_BASE_PATH: "/tmp/e2e-plugins",
},
}),
[
"E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION",
"E2E_TENANT_SETTINGS_ACCOUNT_PATH",
"E2E_TENANT_SETTINGS_PROFILE_PATH",
"E2E_TENANT_MEMBERS_PATH",
"E2E_TENANT_ROLES_AND_PERMISSIONS_PATH",
"E2E_TENANT_NOTIFICATIONS_PATH",
"E2E_TENANT_LOGS_PATH",
"E2E_TENANT_LOG_EVENTS_PATH",
"E2E_TENANT_AUDIT_EXPORT_PATH",
"E2E_TENANT_PRIVACY_PATH",
"E2E_TENANT_CHATBOT_PATH",
"E2E_TENANT_AI_USAGE_PATH",
"E2E_TENANT_API_PATH",
"E2E_TENANT_API_KEYS_PATH",
"E2E_TENANT_API_DOCS_PATH",
"E2E_TENANT_API_LOGS_PATH",
"E2E_TENANT_POLICIES_PATH",
"E2E_TENANT_PORTAL_ANALYTICS_PATH",
"E2E_TENANT_PORTAL_SETTINGS_PATH",
"E2E_ADMIN_EMAIL_SEQUENCES_PATH",
"E2E_PLUGIN_SETTINGS_PATH",
"E2E_PLUGIN_READONLY_EMAIL",
"E2E_PLUGIN_READONLY_PASSWORD",
"E2E_WORKFLOW_ENGINE_PATH",
"E2E_WORKFLOW_BUILDER_PATH",
"E2E_WORKFLOW_RUN_API_PATH",
"E2E_WORKFLOW_WEBHOOK_BUILDER_PATH",
"E2E_WORKFLOW_WAITING_BUILDER_PATH",
"E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH",
"E2E_WORKFLOW_WAITING_RUN_API_PATH",
"E2E_WORKFLOW_WAITING_EXECUTIONS_PATH",
"E2E_WORKFLOW_WAITING_EXECUTION_ID",
]
);
});
it("rejects invalid explicit registration email verification readiness assertions", () => {
assert.deepEqual(
missingReadinessEnv({
env: completeReadinessEnv({
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "yes",
}),
}),
["E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION=0|1"]
);
});
it("keeps persistent plugin lifecycle E2E flows behind explicit opt-in", () => {
const source = readFileSync(
"tests/e2e/plugin-settings.mobile-a11y.spec.ts",
"utf8"
);
assert.match(source, /skipOrFailWhenE2eOptInMissing/u);
assert.match(source, /E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION/u);
assert.match(source, /E2E_ALLOW_PLUGIN_SETTINGS_WRITE/u);
assert.match(
source,
/test\("capability grant and revoke persist for the seeded readiness plugin"[\s\S]*?skipUnlessPluginLifecycleMutationAllowed\("capability grant\/revoke persistence flow"\)/u
);
assert.match(
source,
/test\("lifecycle controls persist enable and disable state for the seeded lifecycle plugin"[\s\S]*?skipUnlessPluginLifecycleMutationAllowed\("enable\/disable persistence flow"\)/u
);
});
it("keeps mutating registration and account E2E flows readiness-aware behind explicit opt-ins", () => {
const source = readFileSync(
"tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts",
"utf8"
);
assert.match(source, /skipOrFailWhenE2eOptInMissing\("E2E_ALLOW_REGISTRATION_CREATE"/u);
assert.match(source, /skipOrFailWhenE2eOptInMissing\("E2E_ALLOW_ACCOUNT_CREATE"/u);
assert.match(source, /skipOrFailWhenE2eOptInMissing\("E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE"/u);
assert.doesNotMatch(source, /test\.skip\(process\.env\.E2E_ALLOW_(?:REGISTRATION_CREATE|ACCOUNT_CREATE|NO_TENANT_ACCOUNT_CREATE)/u);
});
it("keeps the local mutating E2E readiness lane explicit and documented", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
const script = packageJson.scripts["verify:e2e-mutating-readiness:local"];
const docs = readFileSync("docs/testing/e2e-readiness.md", "utf8");
const requiredFlags = [
"REQUIRE_E2E_MUTATING_READINESS=1",
"E2E_ALLOW_REGISTRATION_CREATE=1",
"E2E_ALLOW_ACCOUNT_CREATE=1",
"E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE=1",
"E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION=1",
"E2E_ALLOW_PLUGIN_SETTINGS_WRITE=1",
];
assert.equal(typeof script, "string");
assert.match(script, /pnpm run verify:e2e-a11y/u);
assert.match(docs, /verify:e2e-mutating-readiness:local/u);
for (const flag of requiredFlags) {
assert.match(script, new RegExp(flag, "u"));
assert.match(docs, new RegExp(flag, "u"));
}
assert.match(docs, /E2E_NO_TENANT_EMAIL/u);
assert.match(docs, /E2E_NO_TENANT_PASSWORD/u);
});
it("keeps the local worker-drain E2E readiness lane explicit and documented", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
const script = packageJson.scripts["verify:e2e-mutating-readiness:worker-local"];
const docs = readFileSync("docs/testing/e2e-readiness.md", "utf8");
assert.equal(typeof script, "string");
assert.match(script, /E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1/u);
assert.match(script, /pnpm run verify:e2e-mutating-readiness:local/u);
assert.match(docs, /verify:e2e-mutating-readiness:worker-local/u);
assert.match(docs, /E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1/u);
assert.match(docs, /WorkflowExecution\.status/u);
});
it("keeps real workflow webhook E2E runs behind explicit opt-in and target URL", () => {
const source = readFileSync(
"tests/e2e/workflow-builder-run.mobile-a11y.spec.ts",
"utf8"
);
assert.match(source, /E2E_ALLOW_WORKFLOW_WEBHOOK_RUN/u);
assert.match(source, /E2E_WORKFLOW_WEBHOOK_TARGET_URL/u);
assert.match(source, /E2E_WORKFLOW_WEBHOOK_BUILDER_PATH/u);
assert.match(source, /E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH/u);
assert.match(source, /E2E workflow webhook run opt-in missing/u);
assert.match(
source,
/if \(allowWorkflowWebhookRun\)[\s\S]*?requireWorkflowWebhookRunReadiness\(\)[\s\S]*?else[\s\S]*?skipOrFailWhenE2eInputsMissing/u
);
assert.match(
source,
/if \(allowWorkflowWebhookRun\)[\s\S]*?workflowWebhookTargetUrl![\s\S]*?else[\s\S]*?interceptCurrentRoutePost/u
);
});
it("runs Playwright when runtime and target are reachable", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 0, stdout: "", stderr: "" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const stdout = captureLines();
const outcome = runPlaywrightA11y({
env: { E2E_BASE_URL: "https://staging.example.test" },
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "pass", reason: "ok", exitCode: 0 });
const runCall = spawn.calls.find((call) => call.command === "pnpm" && call.args[0] === "exec");
assert.ok(runCall, "expected playwright test to be invoked");
assert.deepEqual(runCall.args, ["exec", "playwright", "test", "--config=playwright.config.ts"]);
assert.deepEqual(runCall.options, { stdio: "inherit" });
});
it("returns the Playwright exit status when the suite fails", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
pnpm: () => ({ status: 3, stdout: "", stderr: "failed" }),
curl: () => ({ status: 0, stdout: "200", stderr: "" }),
});
const outcome = runPlaywrightA11y({ env: {}, spawn, stdout: captureLines() });
assert.deepEqual(outcome, { status: "fail", reason: "non-zero-exit", exitCode: 3 });
});
it("treats a valid curl status body as reachable", () => {
const spawn = recordingSpawn({
curl: () => ({ status: 0, stdout: "503", stderr: "" }),
});
assert.equal(isUrlReachable("https://degraded.example.test", { spawn }), true);
});
it("treats server errors as unreachable in strict readiness mode", () => {
const spawn = recordingSpawn({
npx: ({ args }) => {
if (args[0] === "--no-install") {
return { status: 0, stdout: "Version 1.59.1", stderr: "" };
}
return { status: 0, stdout: "", stderr: "" };
},
curl: () => ({ status: 0, stdout: "503", stderr: "" }),
});
const stdout = captureLines();
assert.equal(isUrlReachable("https://degraded.example.test", { spawn, allowServerErrors: false }), false);
const outcome = runPlaywrightA11y({
env: { E2E_BASE_URL: "https://degraded.example.test", REQUIRE_E2E_A11Y: "1" },
spawn,
stdout,
});
assert.deepEqual(outcome, { status: "fail", reason: "target-unreachable", exitCode: 1 });
assert.deepEqual(stdout.lines, ["FAIL: Target URL https://degraded.example.test not reachable while REQUIRE_E2E_A11Y=1."]);
assert.equal(spawn.calls.filter((call) => call.command === "pnpm").length, 0);
});
});
@@ -0,0 +1,127 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectRemovedModuleSourceFiles,
findRemovedModuleMatches,
renderRemovedModuleScanResult,
scanRemovedModules,
} from "./scan-removed-modules.ts";
const tempRoots: string[] = [];
async function createFixtureRoot() {
const root = await mkdtemp(path.join(os.tmpdir(), "removed-modules-"));
tempRoots.push(root);
return root;
}
async function writeFixtureFile(root: string, relativePath: string, content: string) {
const absolutePath = path.join(root, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content, "utf8");
return absolutePath;
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("removed-module scanner", () => {
it("passes for clean runtime roots and reports observability counters", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "apps/web/app/root.tsx", "export const Root = () => null;\n");
await writeFixtureFile(root, "apps/worker/index.mjs", "export const worker = true;\n");
await writeFixtureFile(root, "packages/core/manifest.json", "{\"name\":\"core\"}\n");
const result = await scanRemovedModules(root);
const output = renderRemovedModuleScanResult(result);
expect(result.roots).toEqual(["apps", "packages"]);
expect(result.missingRoots).toEqual([]);
expect(result.files).toEqual(["apps/web/app/root.tsx", "apps/worker/index.mjs", "packages/core/manifest.json"]);
expect(result.diagnostics).toEqual([]);
expect(output).toContain("PASS removed-module scan");
expect(output).toContain("files scanned: 3");
expect(output).toContain("matches found: 0");
});
it("detects each forbidden runtime group with path, line, and excerpt", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(
root,
"app/runtime.ts",
[
"const mailer = 'postmark';",
"const referral = 'Rewardful';",
"const block = 'PageBlocks';",
"const demo = 'my-site-demo';",
].join("\n"),
);
const result = await scanRemovedModules(root, ["app"]);
expect(result.diagnostics).toEqual([
{ id: "postmark", file: "app/runtime.ts", line: 1, excerpt: "const mailer = 'postmark';" },
{ id: "rewardful", file: "app/runtime.ts", line: 2, excerpt: "const referral = 'Rewardful';" },
{ id: "pageblocks", file: "app/runtime.ts", line: 3, excerpt: "const block = 'PageBlocks';" },
{ id: "site-demo", file: "app/runtime.ts", line: 4, excerpt: "const demo = 'my-site-demo';" },
]);
expect(renderRemovedModuleScanResult(result)).toContain("app/runtime.ts:4: [site-demo] const demo = 'my-site-demo';");
});
it("detects supported site-demo spellings and singular PageBlock", () => {
const diagnostics = findRemovedModuleMatches("Site-Demo\nsite demo\ndemo site\nSiteDemo\nPageBlock\n", "app/demo.ts");
expect(diagnostics.map((diagnostic) => diagnostic.id)).toEqual(["site-demo", "site-demo", "site-demo", "site-demo", "pageblocks"]);
});
it("ignores dependency, build, generated, cache, TC, optional plugin, test, fixture, story, and mock paths", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "app/entry.ts", "export const entry = true;\n");
await writeFixtureFile(root, "app/node_modules/pkg/index.ts", "postmark\n");
await writeFixtureFile(root, "app/build/server.js", "rewardful\n");
await writeFixtureFile(root, "app/.cache/cache.ts", "PageBlocks\n");
await writeFixtureFile(root, "app/.tc/plan.ts", "SiteDemo\n");
await writeFixtureFile(root, "app/generated/client.ts", "postmark\n");
await writeFixtureFile(root, "app/component.test.ts", "rewardful\n");
await writeFixtureFile(root, "app/component.stories.tsx", "PageBlocks\n");
await writeFixtureFile(root, "app/fixtures/data.ts", "demo site\n");
await writeFixtureFile(root, "app/__mocks__/mail.ts", "postmark\n");
await writeFixtureFile(root, "packages/plugins/index.ts", "rewardful\n");
await writeFixtureFile(root, "packages/core/types.d.ts", "declare const SiteDemo: string;\n");
await writeFixtureFile(root, "packages/core/bundle.min.js", "postmark\n");
await writeFixtureFile(root, "packages/core/source.js.map", "rewardful\n");
const result = await scanRemovedModules(root, ["app", "packages"]);
expect(result.files).toEqual(["app/entry.ts"]);
expect(result.diagnostics).toEqual([]);
});
it("records absent roots without failing collection", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "packages/core/index.ts", "export const core = true;\n");
const result = await collectRemovedModuleSourceFiles(root, ["app", "apps", "packages"]);
expect(result.roots).toEqual(["packages"]);
expect(result.missingRoots).toEqual(["app", "apps"]);
expect(result.files.map((file) => path.relative(root, file).split(path.sep).join("/"))).toEqual(["packages/core/index.ts"]);
});
it("scans markdown and mdx files inside runtime roots", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "packages/docs/runtime.md", "Legacy PageBlocks reference\n");
await writeFixtureFile(root, "packages/docs/runtime.mdx", "Legacy site demo reference\n");
const result = await scanRemovedModules(root, ["packages"]);
expect(result.diagnostics).toEqual([
{ id: "pageblocks", file: "packages/docs/runtime.md", line: 1, excerpt: "Legacy PageBlocks reference" },
{ id: "site-demo", file: "packages/docs/runtime.mdx", line: 1, excerpt: "Legacy site demo reference" },
]);
});
});
@@ -0,0 +1,322 @@
#!/usr/bin/env tsx
/* eslint-disable no-console */
import { readdir, readFile, stat } from "node:fs/promises";
import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const repoRoot = path.resolve(__dirname, "..");
export const removedModuleScanRoots = ["apps", "packages"] as const;
export const sourceFileExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".md", ".mdx"]);
export const ignoredDirectoryNames = new Set([
".cache",
".git",
".next",
".react-router",
".tc",
".turbo",
".vite",
"build",
"cache",
"coverage",
"dist",
"generated",
"node_modules",
"out",
"storybook-static",
"tmp",
"temp",
"vendor",
]);
const ignoredFileNames = new Set(["package-lock.json", "pnpm-lock.yaml", "yarn.lock"]);
export const forbiddenRemovedModulePatterns = [
{ id: "postmark", pattern: /\bpostmark\b/i },
{ id: "rewardful", pattern: /\brewardful\b/i },
{ id: "pageblocks", pattern: /\bPageBlocks?\b/i },
{ id: "site-demo", pattern: /\b(?:Site-Demo|site demo|demo site|SiteDemo|my-site-demo)\b/i },
] as const;
const removedModulePrefilterPatterns = ["postmark", "rewardful", "PageBlock", "Site-Demo", "site demo", "demo site", "SiteDemo", "my-site-demo"] as const;
const maxReadConcurrency = 16;
const maxPrefilterArgBytes = 100_000;
export type RemovedModuleGroupId = (typeof forbiddenRemovedModulePatterns)[number]["id"];
export interface RemovedModuleDiagnostic {
id: RemovedModuleGroupId;
file: string;
line: number;
excerpt: string;
}
export interface RemovedModuleScanResult {
roots: string[];
missingRoots: string[];
files: string[];
diagnostics: RemovedModuleDiagnostic[];
}
export function normalizeRelativePath(relativePath: string) {
return relativePath.split(path.sep).join("/").replace(/^\.\//, "");
}
function toRelativePath(root: string, absolutePath: string) {
return normalizeRelativePath(path.relative(root, absolutePath));
}
export function isIgnoredDirectory(name: string, relativePath = name) {
const normalized = normalizeRelativePath(relativePath);
return ignoredDirectoryNames.has(name) || ignoredDirectoryNames.has(normalized) || name.endsWith(".cache") || normalized.includes("/__generated__/");
}
export function isGeneratedOrIgnoredFile(filePath: string) {
const name = path.basename(filePath);
return (
ignoredFileNames.has(name) ||
name.endsWith(".d.ts") ||
name.endsWith(".generated.ts") ||
name.endsWith(".generated.tsx") ||
name.endsWith(".gen.ts") ||
name.endsWith(".gen.tsx") ||
name.endsWith(".min.js") ||
name.endsWith(".map")
);
}
export function isTestStoryFixtureOrMockFile(filePath: string) {
const normalized = normalizeRelativePath(filePath);
const name = path.basename(normalized);
return (
/(?:^|[._-])(?:test|spec|stories|story|fixture|fixtures|mock|mocks)\.[cm]?[jt]sx?$/.test(name) ||
/\.(?:test|spec|stories|story|fixture|fixtures|mock|mocks)\.[cm]?[jt]sx?$/.test(name) ||
normalized.includes("/__tests__/") ||
normalized.includes("/test/") ||
normalized.includes("/tests/") ||
normalized.includes("/fixtures/") ||
normalized.includes("/fixture/") ||
normalized.includes("/mocks/") ||
normalized.includes("/__mocks__/")
);
}
export function isOptionalPluginOutput(relativePath: string) {
const normalized = normalizeRelativePath(relativePath);
return normalized === "packages/plugins" || normalized.startsWith("packages/plugins/");
}
export function isScannableSourceFile(filePath: string) {
return sourceFileExtensions.has(path.extname(filePath)) && !isGeneratedOrIgnoredFile(filePath);
}
async function pathExists(root: string, relativePath: string) {
try {
await stat(path.join(root, relativePath));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return false;
throw error;
}
}
export async function collectRemovedModuleSourceFiles(root = repoRoot, scanRoots: readonly string[] = removedModuleScanRoots) {
const roots: string[] = [];
const missingRoots: string[] = [];
const files: string[] = [];
async function walk(directory: string) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name);
const relativePath = toRelativePath(root, absolutePath);
if (entry.isDirectory()) {
if (isIgnoredDirectory(entry.name, relativePath) || isOptionalPluginOutput(relativePath)) continue;
await walk(absolutePath);
continue;
}
if (!entry.isFile()) continue;
if (!isScannableSourceFile(entry.name)) continue;
if (isOptionalPluginOutput(relativePath) || isTestStoryFixtureOrMockFile(relativePath)) continue;
files.push(absolutePath);
}
}
for (const scanRoot of scanRoots) {
const normalizedRoot = normalizeRelativePath(scanRoot);
if (!(await pathExists(root, normalizedRoot))) {
missingRoots.push(normalizedRoot);
continue;
}
roots.push(normalizedRoot);
const absoluteRoot = path.join(root, normalizedRoot);
const rootStat = await stat(absoluteRoot);
if (rootStat.isFile()) {
if (isScannableSourceFile(absoluteRoot) && !isTestStoryFixtureOrMockFile(normalizedRoot) && !isOptionalPluginOutput(normalizedRoot)) files.push(absoluteRoot);
continue;
}
await walk(absoluteRoot);
}
files.sort((a, b) => toRelativePath(root, a).localeCompare(toRelativePath(root, b)));
return { roots, missingRoots, files };
}
export function findRemovedModuleMatches(sourceText: string, relativeFilePath: string) {
const diagnostics: RemovedModuleDiagnostic[] = [];
const lines = sourceText.split(/\r?\n/);
lines.forEach((line, index) => {
for (const { id, pattern } of forbiddenRemovedModulePatterns) {
pattern.lastIndex = 0;
if (!pattern.test(line)) continue;
diagnostics.push({ id, file: normalizeRelativePath(relativeFilePath), line: index + 1, excerpt: line.trim() });
}
});
return diagnostics;
}
function chunkFilesForPrefilter(files: readonly string[]) {
const chunks: string[][] = [];
let chunk: string[] = [];
let chunkBytes = 0;
for (const file of files) {
const fileBytes = Buffer.byteLength(file) + 1;
if (chunk.length > 0 && chunkBytes + fileBytes > maxPrefilterArgBytes) {
chunks.push(chunk);
chunk = [];
chunkBytes = 0;
}
chunk.push(file);
chunkBytes += fileBytes;
}
if (chunk.length > 0) chunks.push(chunk);
return chunks;
}
async function runRipgrepPrefilter(root: string, files: readonly string[]) {
if (files.length === 0) return [];
const matchingFiles = new Set<string>();
const chunks = chunkFilesForPrefilter(files);
for (const chunk of chunks) {
const args = [
"--files-with-matches",
"--fixed-strings",
"--ignore-case",
"--null",
"--no-ignore",
"--hidden",
"--text",
"--no-messages",
...removedModulePrefilterPatterns.flatMap((pattern) => ["--regexp", pattern]),
"--",
...chunk,
];
const { code, output } = await new Promise<{ code: number | null; output: string }>((resolve, reject) => {
const child = spawn("rg", args, { cwd: root });
const outputChunks: Buffer[] = [];
const errorChunks: Buffer[] = [];
child.stdout.on("data", (data: Buffer) => outputChunks.push(data));
child.stderr.on("data", (data: Buffer) => errorChunks.push(data));
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0 && code !== 1) {
const details = Buffer.concat(errorChunks).toString("utf8").trim();
reject(new Error(`removed-module prefilter failed with exit code ${code}${details ? `: ${details}` : ""}`));
return;
}
resolve({ code, output: Buffer.concat(outputChunks).toString("utf8") });
});
});
if (code === 1 || output.length === 0) continue;
for (const file of output.split("\0")) {
if (file.length === 0) continue;
matchingFiles.add(normalizeRelativePath(path.isAbsolute(file) ? path.relative(root, file) : file));
}
}
return files.filter((file) => matchingFiles.has(toRelativePath(root, file)));
}
export async function prefilterRemovedModuleCandidateFiles(root: string, files: readonly string[]) {
try {
return await runRipgrepPrefilter(root, files);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return [...files];
throw error;
}
}
async function mapWithConcurrency<T, R>(items: readonly T[], limit: number, mapper: (item: T, index: number) => Promise<R>) {
const results = new Array<R>(items.length);
let nextIndex = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
});
await Promise.all(workers);
return results;
}
export async function scanRemovedModules(root = repoRoot, scanRoots: readonly string[] = removedModuleScanRoots): Promise<RemovedModuleScanResult> {
const collection = await collectRemovedModuleSourceFiles(root, scanRoots);
const candidateFiles = await prefilterRemovedModuleCandidateFiles(root, collection.files);
const diagnosticsByFile = await mapWithConcurrency(candidateFiles, maxReadConcurrency, async (file) => {
const relativeFilePath = toRelativePath(root, file);
const sourceText = await readFile(file, "utf8");
return findRemovedModuleMatches(sourceText, relativeFilePath);
});
const diagnostics = diagnosticsByFile.flat();
return { roots: collection.roots, missingRoots: collection.missingRoots, files: collection.files.map((file) => toRelativePath(root, file)), diagnostics };
}
export function renderRemovedModuleScanResult(result: RemovedModuleScanResult) {
const lines = [
`removed-module scan roots: ${result.roots.join(", ") || "(none)"}`,
`removed-module missing roots skipped: ${result.missingRoots.join(", ") || "(none)"}`,
`files scanned: ${result.files.length}`,
`matches found: ${result.diagnostics.length}`,
];
if (result.diagnostics.length === 0) {
lines.unshift("PASS removed-module scan: no forbidden runtime references found");
return lines.join("\n");
}
lines.unshift("FAIL removed-module scan: forbidden runtime references found");
for (const diagnostic of result.diagnostics) {
lines.push(`${diagnostic.file}:${diagnostic.line}: [${diagnostic.id}] ${diagnostic.excerpt}`);
}
return lines.join("\n");
}
async function main() {
const result = await scanRemovedModules(repoRoot);
const output = renderRemovedModuleScanResult(result);
if (result.diagnostics.length > 0) {
console.error(output);
process.exitCode = 1;
return;
}
console.log(output);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
@@ -0,0 +1,339 @@
import { describe, expect, test } from "vitest";
import { defaultAppRoleSeeds, defaultPermissionSeedDescriptors } from "@toolctrl/db/permissions-seed.server";
import { missingReadinessEnv } from "./run-playwright-a11y.mjs";
import {
E2E_READINESS_API_KEY_ALIAS,
E2E_READINESS_API_LOG_ENDPOINT,
E2E_READINESS_API_LOG_METHOD,
E2E_READINESS_PLUGIN_ID,
E2E_READINESS_PLUGIN_VERSION,
E2E_READINESS_WEBHOOK_WORKFLOW_ID,
E2E_READINESS_WAITING_EXECUTION_ID,
E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID,
E2E_READINESS_WAITING_WORKFLOW_ID,
E2E_READINESS_WORKFLOW_EXECUTION_ID,
E2E_READINESS_WORKFLOW_ID,
E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID,
E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION,
E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER,
E2E_READINESS_LIFECYCLE_PLUGIN_ID,
E2E_READINESS_LIFECYCLE_PLUGIN_VERSION,
E2E_READINESS_NO_TENANT_EMAIL,
E2E_READINESS_NO_TENANT_PASSWORD,
E2E_READINESS_PLUGIN_READONLY_EMAIL,
E2E_READINESS_PLUGIN_READONLY_PASSWORD,
E2E_READINESS_PLUGIN_READONLY_ROLE_NAME,
E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID,
E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID,
E2E_READINESS_TENANT_NAME,
E2E_READINESS_TENANT_SLUG,
E2E_READINESS_UPGRADE_PLAN_STRIPE_ID,
E2E_READINESS_UPGRADE_PRICE_STRIPE_ID,
E2E_READINESS_USER_EMAIL,
E2E_READINESS_USER_PASSWORD,
appFixturePath,
assertCanRunE2eReadinessSeed,
buildE2eReadinessEnv,
buildE2eReadinessEmailSequenceDefinitions,
buildE2eReadinessLifecyclePluginManifest,
buildE2eReadinessManualWorkflowDefinition,
buildE2eReadinessPluginManifest,
buildE2eReadinessPluginSettingsHtml,
buildE2eReadinessWaitingWorkflowDefinition,
buildE2eReadinessWebhookWorkflowDefinition,
renderE2eReadinessEnvFile,
} from "./seed-e2e-readiness";
describe("seed-e2e-readiness", () => {
test("refuses production and requires an explicit local opt-in", () => {
expect(() =>
assertCanRunE2eReadinessSeed({
DATABASE_URL: "postgresql://localhost/app",
E2E_LOCAL_READINESS_SEED: "1",
NODE_ENV: "production",
})
).toThrow("production");
expect(() =>
assertCanRunE2eReadinessSeed({
DATABASE_URL: "postgresql://localhost/app",
NODE_ENV: "development",
})
).toThrow("E2E_LOCAL_READINESS_SEED=1");
expect(() =>
assertCanRunE2eReadinessSeed({
E2E_LOCAL_READINESS_SEED: "1",
NODE_ENV: "development",
})
).toThrow("DATABASE_URL");
});
test("builds the readiness env contract consumed by the Playwright gate", () => {
const env = buildE2eReadinessEnv({
tenantSlug: E2E_READINESS_TENANT_SLUG,
workflowId: "wf_123",
webhookWorkflowId: "wf_webhook_123",
waitingWorkflowId: "wf_waiting_123",
waitingExecutionId: "wfe_waiting_123",
pluginBasePath: "/tmp/e2e-plugins",
});
expect(env).toMatchObject({
E2E_TENANT_EMAIL: E2E_READINESS_USER_EMAIL,
E2E_TENANT_PASSWORD: E2E_READINESS_USER_PASSWORD,
E2E_NO_TENANT_EMAIL: E2E_READINESS_NO_TENANT_EMAIL,
E2E_NO_TENANT_PASSWORD: E2E_READINESS_NO_TENANT_PASSWORD,
E2E_PLUGIN_READONLY_EMAIL: E2E_READINESS_PLUGIN_READONLY_EMAIL,
E2E_PLUGIN_READONLY_PASSWORD: E2E_READINESS_PLUGIN_READONLY_PASSWORD,
E2E_TENANT_ACCOUNT_NAME: E2E_READINESS_TENANT_NAME,
E2E_TENANT_ACCOUNT_SLUG: E2E_READINESS_TENANT_SLUG,
E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION: "0",
E2E_TENANT_DASHBOARD_PATH: "/e2e-readiness/dashboard",
E2E_TENANT_SETTINGS_ACCOUNT_PATH: "/e2e-readiness/settings/account",
E2E_TENANT_SETTINGS_PROFILE_PATH: "/e2e-readiness/settings/profile",
E2E_TENANT_SUBSCRIPTION_PATH: "/e2e-readiness/settings/subscription",
E2E_TENANT_MEMBERS_PATH: "/e2e-readiness/settings/members",
E2E_TENANT_ROLES_AND_PERMISSIONS_PATH: "/e2e-readiness/settings/roles-and-permissions",
E2E_TENANT_NOTIFICATIONS_PATH: "/e2e-readiness/settings/notifications",
E2E_TENANT_LOGS_PATH: "/e2e-readiness/settings/logs",
E2E_TENANT_LOG_EVENTS_PATH: "/e2e-readiness/settings/logs/events",
E2E_TENANT_AUDIT_EXPORT_PATH: "/e2e-readiness/settings/audit-export",
E2E_TENANT_PRIVACY_PATH: "/e2e-readiness/settings/privacy",
E2E_TENANT_CHATBOT_PATH: "/e2e-readiness/settings/chatbot",
E2E_TENANT_AI_USAGE_PATH: "/e2e-readiness/settings/ai-usage",
E2E_TENANT_API_PATH: "/e2e-readiness/settings/api",
E2E_TENANT_API_KEYS_PATH: "/e2e-readiness/settings/api/keys",
E2E_TENANT_API_DOCS_PATH: "/e2e-readiness/settings/api/docs",
E2E_TENANT_API_LOGS_PATH: "/e2e-readiness/settings/api/logs",
E2E_TENANT_POLICIES_PATH: "/e2e-readiness/settings/policies",
E2E_ADMIN_EMAIL_SEQUENCES_PATH: "/email-sequences",
E2E_PLUGIN_SETTINGS_PATH: "/e2e-readiness/settings/plugins",
E2E_WORKFLOW_ENGINE_PATH: "/e2e-readiness/workflow-engine/workflows",
E2E_WORKFLOW_BUILDER_PATH: "/e2e-readiness/workflow-engine/workflows/wf_123",
E2E_WORKFLOW_RUN_API_PATH: "/e2e-readiness/workflow-engine/workflows/wf_123/run/api",
E2E_WORKFLOW_RUN_MANUAL_PATH: "/e2e-readiness/workflow-engine/workflows/wf_123/run/manual",
E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: "/e2e-readiness/workflow-engine/workflows/wf_webhook_123",
E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: "/e2e-readiness/workflow-engine/workflows/wf_webhook_123/run/manual",
E2E_WORKFLOW_WAITING_BUILDER_PATH: "/e2e-readiness/workflow-engine/workflows/wf_waiting_123",
E2E_WORKFLOW_WAITING_RUN_MANUAL_PATH: "/e2e-readiness/workflow-engine/workflows/wf_waiting_123/run/manual",
E2E_WORKFLOW_WAITING_RUN_API_PATH: "/e2e-readiness/workflow-engine/workflows/wf_waiting_123/run/api",
E2E_WORKFLOW_WAITING_EXECUTIONS_PATH: "/e2e-readiness/workflow-engine/workflows/wf_waiting_123/executions",
E2E_WORKFLOW_WAITING_EXECUTION_ID: "wfe_waiting_123",
E2E_ALLOW_WORKFLOW_RUN: "1",
E2E_ALLOW_WORKFLOW_WEBHOOK_RUN: "0",
E2E_WORKFLOW_WEBHOOK_TARGET_URL: "",
E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN: "0",
E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS: "success",
E2E_WORKFLOW_OUTBOX_DRAIN_ASSERTION: "operator-gated",
E2E_WORKFLOW_OUTBOX_DRAIN_BLOCKER:
"Workflow outbox-drain readiness is operator-gated: set E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1 only when the general worker is running and the seeded manual workflow can reach the expected success execution status.",
PLUGIN_BINARY_BASE_PATH: "/tmp/e2e-plugins",
});
expect(missingReadinessEnv({ env })).toEqual([]);
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_EMAIL="e2e.readiness@example.test"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_NO_TENANT_EMAIL="e2e.no-tenant@example.test"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_NO_TENANT_PASSWORD="NoTenantPass123!"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_PLUGIN_READONLY_EMAIL="e2e.plugin-readonly@example.test"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_PLUGIN_READONLY_PASSWORD="PluginReadOnlyPass123!"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION="0"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_SETTINGS_ACCOUNT_PATH="/e2e-readiness/settings/account"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_MEMBERS_PATH="/e2e-readiness/settings/members"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_PRIVACY_PATH="/e2e-readiness/settings/privacy"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_AI_USAGE_PATH="/e2e-readiness/settings/ai-usage"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_TENANT_API_LOGS_PATH="/e2e-readiness/settings/api/logs"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_ADMIN_EMAIL_SEQUENCES_PATH="/email-sequences"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_ALLOW_WORKFLOW_WEBHOOK_RUN="0"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_WORKFLOW_WEBHOOK_TARGET_URL=""');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_WORKFLOW_WAITING_EXECUTIONS_PATH="/e2e-readiness/workflow-engine/workflows/wf_waiting_123/executions"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_WORKFLOW_WAITING_EXECUTION_ID="wfe_waiting_123"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN="0"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_WORKFLOW_OUTBOX_DRAIN_EXPECTED_STATUS="success"');
expect(renderE2eReadinessEnvFile(env)).toContain('E2E_WORKFLOW_OUTBOX_DRAIN_ASSERTION="operator-gated"');
});
test("documents workflow outbox-drain readiness as an operator-gated expected-success probe", () => {
expect(E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_ASSERTION).toBe("operator-gated");
expect(E2E_READINESS_WORKFLOW_OUTBOX_DRAIN_BLOCKER).toBe(
"Workflow outbox-drain readiness is operator-gated: set E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1 only when the general worker is running and the seeded manual workflow can reach the expected success execution status."
);
});
test("defines deterministic API key and log fixture identifiers", () => {
expect(E2E_READINESS_API_KEY_ALIAS).toBe("E2E Readiness API Key");
expect(E2E_READINESS_API_LOG_ENDPOINT).toBe("/v1/e2e-readiness");
expect(E2E_READINESS_API_LOG_METHOD).toBe("GET");
});
test("defines separate owned and unowned subscription fixtures for billing smokes", () => {
expect(E2E_READINESS_SUBSCRIPTION_PLAN_STRIPE_ID).toBe("e2e_readiness_plan");
expect(E2E_READINESS_SUBSCRIPTION_PRICE_STRIPE_ID).toBe("e2e_readiness_price_monthly");
expect(E2E_READINESS_UPGRADE_PLAN_STRIPE_ID).toBe("e2e_readiness_upgrade_plan");
expect(E2E_READINESS_UPGRADE_PRICE_STRIPE_ID).toBe("e2e_readiness_upgrade_price_monthly");
});
test("keeps the plugin read-only fixture on the default User app role contract", () => {
expect(E2E_READINESS_PLUGIN_READONLY_EMAIL).toBe("e2e.plugin-readonly@example.test");
expect(E2E_READINESS_PLUGIN_READONLY_PASSWORD).toBe("PluginReadOnlyPass123!");
expect(E2E_READINESS_PLUGIN_READONLY_ROLE_NAME).toBe("User");
expect(defaultAppRoleSeeds.find((role) => role.name === E2E_READINESS_PLUGIN_READONLY_ROLE_NAME)).toMatchObject({
type: "app",
assignToNewUsers: true,
});
expect(defaultPermissionSeedDescriptors.find((permission) => permission.name === "app.plugins.use")?.inRoles).toContain(
E2E_READINESS_PLUGIN_READONLY_ROLE_NAME
);
expect(defaultPermissionSeedDescriptors.find((permission) => permission.name === "app.plugins.manage")?.inRoles).not.toContain(
E2E_READINESS_PLUGIN_READONLY_ROLE_NAME
);
});
test("keeps the plugin fixture shaped for consent, revoke, and iframe settings coverage", () => {
const manifest = buildE2eReadinessPluginManifest();
expect(manifest.id).toBe(E2E_READINESS_PLUGIN_ID);
expect(manifest.version).toBe(E2E_READINESS_PLUGIN_VERSION);
expect(manifest.capabilities.map((capability) => capability.name)).toEqual(["log", "audit"]);
expect(manifest.hooks).toEqual([{ name: "workflow.executed", export: "onWorkflowExecuted" }]);
expect(manifest.ui?.settings?.entry).toBe("settings/index.html");
expect(manifest.ui?.settings?.sha256).toMatch(/^[a-f0-9]{64}$/u);
});
test("keeps the plugin settings HTML compatible with the hardened iframe protocol", () => {
const html = buildE2eReadinessPluginSettingsHtml();
expect(html).toContain('meta[name="plugin-id"]');
expect(html).toContain('meta[name="plugin-settings-channel"]');
expect(html).toContain('meta[name="plugin-settings-protocol"]');
expect(html).toContain("parent.postMessage");
expect(html).toContain("type: 'plugin.settings.set'");
expect(html).toContain("version: protocolVersion");
expect(html).toContain("pluginId: pluginId");
expect(html).toContain("channelId: channelId");
expect(html).toContain("requestId: 'seed-fixture-save'");
expect(html).toContain("key: 'theme'");
expect(html).toContain("value: 'ready'");
expect(html).toContain("<script>");
expect(html).not.toContain("onclick=");
});
test("keeps a separate plugin fixture for real lifecycle persistence coverage", () => {
const manifest = buildE2eReadinessLifecyclePluginManifest();
expect(manifest.id).toBe(E2E_READINESS_LIFECYCLE_PLUGIN_ID);
expect(manifest.version).toBe(E2E_READINESS_LIFECYCLE_PLUGIN_VERSION);
expect(manifest.capabilities.map((capability) => capability.name)).toEqual(["log", "audit"]);
expect(manifest.hooks).toEqual([{ name: "workflow.executed", export: "onWorkflowExecuted" }]);
expect(manifest.ui?.settings).toBeUndefined();
});
test("defines deterministic workflow graph fixtures for log-only and webhook readiness", () => {
expect(E2E_READINESS_WORKFLOW_EXECUTION_ID).toBe("wfe_123");
expect(E2E_READINESS_WORKFLOW_LOG_BLOCK_EXECUTION_ID).toBe("wfbe_log_123");
expect(buildE2eReadinessManualWorkflowDefinition()).toMatchObject({
id: E2E_READINESS_WORKFLOW_ID,
blocks: [
{ key: "manual", type: "manual", isTrigger: true, isBlock: false, input: {} },
{ key: "log", type: "log", isTrigger: false, isBlock: true, input: { message: "E2E readiness {{$params.message}}" } },
],
edges: [{ from: "manual", to: "log" }],
inputExample: { title: "Readiness payload", input: { message: "ready" } },
});
expect(buildE2eReadinessWebhookWorkflowDefinition()).toMatchObject({
id: E2E_READINESS_WEBHOOK_WORKFLOW_ID,
blocks: [
{ key: "manual", type: "manual", isTrigger: true, isBlock: false, input: {} },
{
key: "httpRequest",
type: "httpRequest",
isTrigger: false,
isBlock: true,
input: {
url: "{{$params.webhookUrl}}",
method: "POST",
headers: { "content-type": "application/json" },
body: '{"message":"{{$params.message}}"}',
throwsError: true,
},
},
{ key: "log", type: "log", isTrigger: false, isBlock: true, input: { message: "Webhook readiness status {{$httpRequest.statusCode}}" } },
],
edges: [
{ from: "manual", to: "httpRequest" },
{ from: "httpRequest", to: "log" },
],
inputExample: {
title: "Webhook readiness payload",
input: { webhookUrl: "http://127.0.0.1:1/e2e-readiness-webhook", message: "ready" },
},
});
expect(E2E_READINESS_WAITING_WORKFLOW_ID).toBe("wf_waiting_123");
expect(E2E_READINESS_WAITING_EXECUTION_ID).toBe("wfe_waiting_123");
expect(E2E_READINESS_WAITING_MANUAL_BLOCK_EXECUTION_ID).toBe("wfbe_waiting_manual_123");
expect(buildE2eReadinessWaitingWorkflowDefinition()).toMatchObject({
id: E2E_READINESS_WAITING_WORKFLOW_ID,
blocks: [
{ key: "manual", type: "manual", isTrigger: true, isBlock: false, input: {} },
{
key: "wait",
type: "waitForInput",
isTrigger: false,
isBlock: true,
input: { title: "E2E approval", placeholder: "Type approval" },
},
{
key: "variable",
type: "variable",
isTrigger: false,
isBlock: true,
input: { variableName: "approvalMessage", value: "approved {{waitForInput.input.approved}}" },
},
{ key: "log", type: "log", isTrigger: false, isBlock: true, input: { message: "continued {{$vars.approvalMessage}}" } },
],
edges: [
{ from: "manual", to: "wait" },
{ from: "wait", to: "variable" },
{ from: "variable", to: "log" },
],
inputExample: { title: "Waiting readiness payload", input: { input: { approved: false } } },
});
});
test("defines deterministic email sequence fixtures for admin and trigger readiness", () => {
const sequences = buildE2eReadinessEmailSequenceDefinitions("tenant_123");
expect(sequences).toHaveLength(4);
expect(sequences.map((sequence) => sequence.id)).toEqual([
"seq_e2e_welcome",
"seq_e2e_trial_expiring",
"seq_e2e_stale_lead",
"seq_e2e_customer_welcome",
]);
expect(sequences.map((sequence) => sequence.triggerEvent)).toEqual([
"tenant.signup",
"tenant.trial_expiring",
"lead.stale_60d",
"lead.converted_to_customer",
]);
expect(sequences.every((sequence) => sequence.tenantId === "tenant_123")).toBe(true);
expect(sequences.every((sequence) => sequence.isEnabled)).toBe(true);
expect(sequences.every((sequence) => sequence.steps.length === 1)).toBe(true);
expect(sequences.map((sequence) => sequence.steps[0]?.template)).toEqual([
"welcome",
"trial-reminder",
"re-activation",
"generic-step",
]);
});
test("normalizes app fixture paths without host assumptions", () => {
expect(appFixturePath("acme", "dashboard")).toBe("/acme/dashboard");
expect(appFixturePath("acme", "/settings/subscription")).toBe("/acme/settings/subscription");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,78 @@
// Repoints every import from a vendor icon library (lucide-react,
// @radix-ui/react-icons, @heroicons/react/24/outline) to the generated Subframe
// Feather compatibility shim at ~/components/ui/icons/vendor.
//
// Named specifiers (incl. `type X` and `Foo as Bar` aliases) are preserved; when
// a file imports from more than one vendor source they are merged into a single
// import statement to avoid duplicate-import lint errors.
//
// Run AFTER scripts/migrate-vendor-icons-to-feather.mjs, from repo root:
// node scripts/swap-vendor-icon-imports.mjs
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs"
import { join, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(__dirname, "..")
const appRoot = join(repoRoot, "apps/web/app")
const VENDOR_SOURCES = ["lucide-react", "@radix-ui/react-icons", "@heroicons/react/24/outline"]
const SHIM = "~/components/ui/icons/vendor"
function walk(dir, acc = []) {
for (const entry of readdirSync(dir)) {
if (entry === "node_modules" || entry.startsWith(".")) continue
const full = join(dir, entry)
const st = statSync(full)
if (st.isDirectory()) walk(full, acc)
else if (/\.(tsx?|jsx?)$/.test(entry)) acc.push(full)
}
return acc
}
// matches a single-line named import from one of the vendor sources
const importLineRe = new RegExp(
`^[ \\t]*import\\s*(?:type\\s+)?\\{([^}]*)\\}\\s*from\\s*["'](?:${VENDOR_SOURCES.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})["'];?[ \\t]*\\r?\\n`,
"gm"
)
let filesChanged = 0
let importsRewritten = 0
for (const file of walk(appRoot)) {
const src = readFileSync(file, "utf8")
const matches = [...src.matchAll(importLineRe)]
if (matches.length === 0) continue
// collect specifiers across all vendor imports in this file, de-duplicated
const specs = []
const seen = new Set()
for (const m of matches) {
for (const raw of m[1].split(",")) {
const spec = raw.trim().replace(/\s+/g, " ")
if (!spec) continue
if (!seen.has(spec)) {
seen.add(spec)
specs.push(spec)
}
}
}
const merged = `import { ${specs.join(", ")} } from "${SHIM}";\n`
// replace the first vendor import with the merged statement, drop the rest
let replacedFirst = false
const out = src.replace(importLineRe, () => {
if (!replacedFirst) {
replacedFirst = true
return merged
}
return ""
})
writeFileSync(file, out, "utf8")
filesChanged++
importsRewritten += matches.length
}
console.log(`Repointed vendor icon imports in ${filesChanged} files (${importsRewritten} import statements) -> ${SHIM}`)
@@ -0,0 +1,177 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
checkTenantIsolationReport,
collectTenantIsolationEvidence,
discoverIsolationTestPaths,
renderTenantIsolationReport,
REPORT_PATH,
runTenantIsolationReportCli,
S04_SUMMARY_PATH,
writeTenantIsolationReport,
} from "./tenant-isolation-report";
let tempRoot: string;
function writeFixture(relativePath: string, content: string): void {
const absolutePath = path.join(tempRoot, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, content, "utf8");
}
function seedIsolationFixtures(): void {
writeFixture(
S04_SUMMARY_PATH,
`---
verification_result: passed with apiKey: "s04-api-key-should-not-leak"
patterns_established:
- tenant_a_session keeps row_iso_secret_001 inside the resolver tenant boundary.
- relationship_iso_parent_001 and relationship_iso_child_001 require parent and child authorization.
---
**S04 summary for tenant_a_session@example.com and token = "s04-token-secret-value";**
`,
);
writeFixture(
"apps/web/app/test/isolation/admin-routes.test.ts",
`it("admin permission denies tenant_admin_secret@example.com bearer_token_secret_001", () => {
const tenantId = "tenant_iso_alpha";
const token = "admin-token-secret-value";
expect(permission).toEqual("denied");
});
`,
);
writeFixture(
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
`test("returns 403 for foreign tenant row_iso_999 from tenant_b", () => {
const userId = "user_iso_beta";
const apiKey = "api_key_entity_secret_001";
expect(status).toBe(403);
});
it("skips relationship resolver for relationship_iso_parent_abc child relationship_iso_child_def", () => {
const session = "session_iso_gamma";
const logId = "log_iso_delta";
expect(repository.update).not.toHaveBeenCalled();
});
`,
);
}
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "tenant-isolation-report-"));
seedIsolationFixtures();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempRoot, { force: true, recursive: true });
});
describe("tenant isolation report renderer", () => {
it("renders deterministic report sections and evidence labels", () => {
const evidence = collectTenantIsolationEvidence(tempRoot);
const firstReport = renderTenantIsolationReport(evidence);
const secondReport = renderTenantIsolationReport(collectTenantIsolationEvidence(tempRoot));
expect(secondReport).toBe(firstReport);
expect(firstReport).toContain("## Scope");
expect(firstReport).toContain("## Source Evidence");
expect(firstReport).toContain("## Boundary Matrix");
expect(firstReport).toContain("| Source | Test count | Checked access boundaries | Deny-path evidence |");
expect(firstReport).toContain("## Prior Slice Evidence");
expect(firstReport).toContain("## Deny-Path Coverage");
expect(firstReport).toContain("## Redaction Rules");
expect(firstReport).toContain("## Verification Commands");
expect(firstReport).toContain("## Redaction Contract");
expect(firstReport).toContain("admin permission gate");
expect(firstReport).toContain("tenant and user scoped resolver");
expect(firstReport).toContain("relationship parent/child authorization");
expect(firstReport).toContain("403 response for unauthorized tenant access");
expect(firstReport).toContain("denied path skips data mutation or lookup");
expect(firstReport).toContain("pnpm exec vitest run scripts/tenant-isolation-report.test.ts");
expect(firstReport).toContain("pnpm exec tsx scripts/tenant-isolation-report.ts --check");
expect(firstReport).toContain("pnpm exec tsx scripts/docs-link-check.ts");
});
it("redacts concrete fixture identifiers, credentials, and source snippets while keeping categories", () => {
const report = renderTenantIsolationReport(collectTenantIsolationEvidence(tempRoot));
expect(report).toContain("tenant A");
expect(report).toContain("tenant B");
expect(report).toContain("API credential");
expect(report).toContain("row record");
expect(report).toContain("relationship record");
expect(report).toContain("user session");
expect(report).not.toContain("tenant_admin_secret@example.com");
expect(report).not.toContain("tenant_a_session@example.com");
expect(report).not.toContain("tenant_iso_alpha");
expect(report).not.toContain("tenant_b");
expect(report).not.toContain("row_iso_999");
expect(report).not.toContain("relationship_iso_parent_abc");
expect(report).not.toContain("relationship_iso_child_def");
expect(report).not.toContain("user_iso_beta");
expect(report).not.toContain("session_iso_gamma");
expect(report).not.toContain("log_iso_delta");
expect(report).not.toContain("admin-token-secret-value");
expect(report).not.toContain("api_key_entity_secret_001");
expect(report).not.toContain("s04-api-key-should-not-leak");
expect(report).not.toContain("s04-token-secret-value");
expect(report).not.toContain("expect(repository.update).not.toHaveBeenCalled();");
expect(report).not.toContain("const tenantId");
});
it("reports missing evidence files with concise diagnostics", () => {
fs.rmSync(path.join(tempRoot, "apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts"));
expect(() => collectTenantIsolationEvidence(tempRoot)).toThrow(
"missing expected isolation evidence file: apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
);
});
it("discovers new isolation tests under curated isolation roots", () => {
writeFixture(
"apps/api/src/isolation/api-key-entity-rows.test.ts",
`it("returns 403 for cross-tenant API-key row access", () => {
expect(status).toBe(403);
});
`,
);
expect(discoverIsolationTestPaths(tempRoot)).toEqual([
"apps/api/src/isolation/api-key-entity-rows.test.ts",
"apps/web/app/test/isolation/admin-routes.test.ts",
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
]);
const evidence = collectTenantIsolationEvidence(tempRoot);
expect(evidence.generatedFrom).toContain("apps/api/src/isolation/api-key-entity-rows.test.ts");
});
it("detects stale generated output in check mode without leaking fixture content", () => {
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
writeTenantIsolationReport(tempRoot);
fs.writeFileSync(path.join(tempRoot, REPORT_PATH), "# stale report with tenant_iso_alpha and api_key_entity_secret_001\n", "utf8");
expect(checkTenantIsolationReport(tempRoot)).toEqual({
ok: false,
reportPath: path.join(tempRoot, REPORT_PATH),
failures: [`stale generated report: ${REPORT_PATH}`],
});
expect(runTenantIsolationReportCli(["--root=" + tempRoot, "--check"])).toBe(1);
const stderrOutput = stderrWrite.mock.calls.map((call) => String(call[0])).join("");
expect(stderrOutput).toContain(`FAIL stale generated report: ${REPORT_PATH}`);
expect(stderrOutput).not.toContain("tenant_iso_alpha");
expect(stderrOutput).not.toContain("api_key_entity_secret_001");
expect(stdoutWrite).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,311 @@
#!/usr/bin/env tsx
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const REPORT_PATH = "docs/security/tenant-isolation-report.md";
export const S04_SUMMARY_PATH = "docs/security/tenant-isolation-s04-summary.md";
export const REQUIRED_ISOLATION_TEST_PATHS = [
"apps/web/app/test/isolation/admin-routes.test.ts",
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
] as const;
export const ISOLATION_TEST_ROOTS = ["apps/web/app/test/isolation", "apps/api/src/isolation"] as const;
type EvidenceSource = {
path: string;
content: string;
};
export type IsolationTestEvidence = {
path: string;
testCount: number;
boundaries: string[];
denyPathEvidence: string[];
};
export type TenantIsolationEvidence = {
generatedFrom: string[];
tests: IsolationTestEvidence[];
s04: {
path: string;
verificationStatus: string;
summary: string;
patterns: string[];
};
};
export type CheckTenantIsolationReportResult = {
ok: boolean;
reportPath: string;
failures: string[];
};
type CliOptions = {
rootDir: string;
check: boolean;
};
const TEST_CASE_PATTERN = /\b(?:it|test)\(\s*(["'`])([\s\S]*?)\1\s*,/g;
const TENANT_ID_PATTERN = /\b(?:tenant|row|relationship|api[_-]?key|log|user|session|token|bearer)[_-](?:session[_-])?(?:iso[_-])?[a-z0-9_-]+\b/gi;
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
const TOKEN_ASSIGNMENT_PATTERN = /\b(?:token|apiKey|api_key|secret|password|bearer)\s*[:=]\s*["'`][^"'`]+["'`]/gi;
const LONG_SECRET_PATTERN = /\b[A-Za-z0-9_-]{24,}\b/g;
const SOURCE_SNIPPET_PATTERN = /[`{};]/g;
const BOUNDARY_RULES: readonly { pattern: RegExp; label: string }[] = [
{ pattern: /admin permission|admin auth|adminOnly|verifyUserHasPermission/i, label: "admin permission gate" },
{ pattern: /resolver tenant|tenant, user, and entity|tenantId|userId/i, label: "tenant and user scoped resolver" },
{ pattern: /relationship resolver|parent and child|authorized rows/i, label: "relationship parent/child authorization" },
{ pattern: /row logs|API-key log|setApiKeyLogStatus|status log/i, label: "API-key status logging boundary" },
{ pattern: /reportUsage|usage/i, label: "tenant-scoped usage reporting" },
];
const DENY_RULES: readonly { pattern: RegExp; label: string }[] = [
{ pattern: /permission is denied|auth is denied|denied/i, label: "denied credentials stop downstream access" },
{ pattern: /returns 403|status: 403|, 403/i, label: "403 response for unauthorized tenant access" },
{ pattern: /not\.toHaveBeenCalled|skips|not called/i, label: "denied path skips data mutation or lookup" },
{ pattern: /foreign|tenant B|cross-tenant/i, label: "foreign tenant resource denied" },
];
function normalizeRelativePath(relativePath: string): string {
return relativePath.split(path.sep).join("/");
}
function uniqueSorted(values: Iterable<string>): string[] {
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
function readRequiredFile(rootDir: string, relativePath: string): EvidenceSource {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(rootDir, normalizedPath);
if (!fs.existsSync(absolutePath)) {
throw new Error(`missing expected isolation evidence file: ${normalizedPath}`);
}
return { path: normalizedPath, content: fs.readFileSync(absolutePath, "utf8") };
}
function walkTestFiles(rootDir: string, relativeRoot: string): string[] {
const absoluteRoot = path.join(rootDir, relativeRoot);
if (!fs.existsSync(absoluteRoot)) return [];
const discovered: string[] = [];
const walk = (absoluteDir: string) => {
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
const absolutePath = path.join(absoluteDir, entry.name);
if (entry.isDirectory()) {
walk(absolutePath);
} else if (entry.isFile() && /\.test\.[cm]?[tj]sx?$/.test(entry.name)) {
discovered.push(normalizeRelativePath(path.relative(rootDir, absolutePath)));
}
}
};
walk(absoluteRoot);
return discovered;
}
export function discoverIsolationTestPaths(rootDir = process.cwd()): string[] {
return uniqueSorted([
...REQUIRED_ISOLATION_TEST_PATHS,
...ISOLATION_TEST_ROOTS.flatMap((testRoot) => walkTestFiles(rootDir, testRoot)),
]);
}
function collectLabels(content: string, rules: readonly { pattern: RegExp; label: string }[]): string[] {
return uniqueSorted(rules.filter((rule) => rule.pattern.test(content)).map((rule) => rule.label));
}
function extractTestNames(content: string): string[] {
return [...content.matchAll(TEST_CASE_PATTERN)].map((match) => match[2].replace(/\s+/g, " ").trim()).filter(Boolean);
}
function parseS04Summary(source: EvidenceSource): TenantIsolationEvidence["s04"] {
const verificationStatus = source.content.match(/^verification_result:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
const headingSummary = source.content.match(/^\*\*(.+?)\*\*/m)?.[1]?.trim() ?? "S04 summary available.";
const patternBlock = source.content.match(/^patterns_established:\n([\s\S]*?)(?:\n[a-z_]+:|\n---)/m)?.[1] ?? "";
const patterns = uniqueSorted([...patternBlock.matchAll(/^\s*-\s+(.+)$/gm)].map((match) => sanitizeSummaryText(match[1])));
return {
path: source.path,
verificationStatus: sanitizeSummaryText(verificationStatus),
summary: sanitizeSummaryText(headingSummary),
patterns,
};
}
function sanitizeSummaryText(value: string): string {
return value
.replace(EMAIL_PATTERN, "[redacted email]")
.replace(TOKEN_ASSIGNMENT_PATTERN, "[redacted credential]")
.replace(UUID_PATTERN, "[redacted id]")
.replace(TENANT_ID_PATTERN, (match) => categoricalLabelFor(match))
.replace(LONG_SECRET_PATTERN, "[redacted token]")
.replace(SOURCE_SNIPPET_PATTERN, "")
.replace(/\s+/g, " ")
.trim();
}
function categoricalLabelFor(value: string): string {
const lower = value.toLowerCase();
if (lower.includes("tenant") && /(?:_a|tenanta|tenant-a| a\b)/i.test(value)) return "tenant A";
if (lower.includes("tenant") && /(?:_b|tenantb|tenant-b| b\b)/i.test(value)) return "tenant B";
if (lower.includes("admin")) return "admin";
if (lower.includes("relationship")) return "relationship record";
if (lower.includes("row")) return "row record";
if (lower.includes("api")) return "API credential";
if (lower.includes("user")) return "user session";
return "[redacted id]";
}
function summarizeIsolationTest(source: EvidenceSource): IsolationTestEvidence {
const testNames = extractTestNames(source.content).map(sanitizeSummaryText);
return {
path: source.path,
testCount: testNames.length,
boundaries: collectLabels(source.content, BOUNDARY_RULES),
denyPathEvidence: uniqueSorted([...testNames.filter((name) => /denied|403|foreign|skips/i.test(name)), ...collectLabels(source.content, DENY_RULES)]),
};
}
export function collectTenantIsolationEvidence(rootDir = process.cwd()): TenantIsolationEvidence {
const tests = discoverIsolationTestPaths(rootDir).map((testPath) => summarizeIsolationTest(readRequiredFile(rootDir, testPath))).sort((a, b) =>
a.path.localeCompare(b.path),
);
const s04Source = readRequiredFile(rootDir, S04_SUMMARY_PATH);
const generatedFrom = uniqueSorted([...tests.map((test) => test.path), s04Source.path]);
return { generatedFrom, tests, s04: parseS04Summary(s04Source) };
}
function renderList(values: string[]): string {
if (values.length === 0) return "- none observed";
return values.map((value) => `- ${value}`).join("\n");
}
export function renderTenantIsolationReport(evidence: TenantIsolationEvidence): string {
const testRows = evidence.tests.map((test) => {
return `| ${test.path} | ${test.testCount} | ${test.boundaries.join("; ") || "none observed"} | ${test.denyPathEvidence.join("; ") || "none observed"} |`;
});
return [
"# Tenant Isolation Report",
"",
"<!-- Generated by scripts/tenant-isolation-report.ts. Do not edit by hand. -->",
"",
"## Scope",
"",
"This report summarizes committed Tenant Isolation tenant-isolation tests and S04 evidence using categorical labels only. Fixture identifiers, credentials, row IDs, relationship IDs, tenant IDs, emails, tokens, and source snippets are intentionally redacted from this document.",
"",
"## Source Evidence",
"",
renderList(evidence.generatedFrom),
"",
"## Boundary Matrix",
"",
"| Source | Test count | Checked access boundaries | Deny-path evidence |",
"| --- | ---: | --- | --- |",
...testRows,
"",
"## Prior Slice Evidence",
"",
`- Source: ${evidence.s04.path}`,
`- Verification status: ${evidence.s04.verificationStatus}`,
`- Summary: ${evidence.s04.summary}`,
"- Established patterns:",
...evidence.s04.patterns.map((pattern) => ` - ${pattern}`),
"",
"## Deny-Path Coverage",
"",
"The boundary matrix records denial outcomes for unauthorized credentials, foreign tenant resources, skipped downstream mutations/lookups, and 403 responses. These entries are derived from test names and assertion patterns rather than concrete fixture values.",
"",
"## Redaction Rules",
"",
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
"",
"## Verification Commands",
"",
"```sh",
"pnpm exec eslint scripts/tenant-isolation-report.ts scripts/tenant-isolation-report.test.ts --max-warnings=0",
"pnpm exec vitest run scripts/tenant-isolation-report.test.ts",
"pnpm exec tsx scripts/tenant-isolation-report.ts --check",
"pnpm exec tsx scripts/docs-link-check.ts",
"```",
"",
"## Redaction Contract",
"",
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
"",
].join("\n");
}
export function writeTenantIsolationReport(rootDir = process.cwd()): string {
const reportPath = path.join(rootDir, REPORT_PATH);
const report = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, report, "utf8");
return reportPath;
}
export function checkTenantIsolationReport(rootDir = process.cwd()): CheckTenantIsolationReportResult {
const reportPath = path.join(rootDir, REPORT_PATH);
const expected = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
if (!fs.existsSync(reportPath)) {
return { ok: false, reportPath, failures: [`missing generated report: ${REPORT_PATH}`] };
}
const actual = fs.readFileSync(reportPath, "utf8");
if (actual !== expected) {
return { ok: false, reportPath, failures: [`stale generated report: ${REPORT_PATH}`] };
}
return { ok: true, reportPath, failures: [] };
}
function parseCliOptions(args: string[]): CliOptions {
const options: CliOptions = { rootDir: process.cwd(), check: false };
for (const arg of args) {
if (arg === "--check") {
options.check = true;
} else if (arg.startsWith("--root=")) {
options.rootDir = path.resolve(arg.slice("--root=".length));
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
return options;
}
function formatCliResult(status: "PASS" | "FAIL", reportPath: string, details: string[]): string {
const relativeReportPath = normalizeRelativePath(path.relative(process.cwd(), reportPath));
return [`${status} tenant-isolation-report ${relativeReportPath}`, ...details].join("\n") + "\n";
}
export function runTenantIsolationReportCli(args = process.argv.slice(2)): number {
try {
const options = parseCliOptions(args);
if (options.check) {
const result = checkTenantIsolationReport(options.rootDir);
const output = formatCliResult(result.ok ? "PASS" : "FAIL", result.reportPath, result.failures.map((failure) => `FAIL ${failure}`));
(result.ok ? process.stdout : process.stderr).write(output);
return result.ok ? 0 : 1;
}
const reportPath = writeTenantIsolationReport(options.rootDir);
process.stdout.write(formatCliResult("PASS", reportPath, ["wrote generated report"]));
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown tenant isolation report failure";
process.stderr.write(`FAIL tenant-isolation-report ${message}\n`);
return 1;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exitCode = runTenantIsolationReportCli();
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
const rootArgs = ["--dir", "../..", "exec", "tsc", "-p"];
const projects = [
"apps/web/tsconfig.core.json",
"apps/web/tsconfig.routes-admin.json",
"apps/web/tsconfig.routes-tenant.json",
"apps/web/tsconfig.routes-other.json",
];
function runProject(project) {
return new Promise((resolve) => {
const child = spawn("pnpm", [...rootArgs, project], {
stdio: "inherit",
env: process.env,
// shell: true lets Windows resolve pnpm.cmd; POSIX is unaffected.
// All args here are space-free paths, so shell quoting is safe.
shell: true,
});
child.on("error", (error) => resolve({ project, code: 1, error }));
child.on("exit", (code, signal) => resolve({ project, code: code ?? 1, signal }));
});
}
const results = await Promise.all(projects.map((project) => runProject(project)));
const failed = results.filter((result) => result.code !== 0);
if (failed.length > 0) {
for (const result of failed) {
const signal = result.signal ? ` signal=${result.signal}` : "";
const error = result.error instanceof Error ? ` ${result.error.message}` : "";
console.error(`[typecheck-web] ${result.project} failed with code=${result.code}${signal}${error}`);
}
process.exit(1);
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# update-geoip-db.sh — lädt die aktuelle DB-IP Lite Country MMDB (CC-BY-4.0) nach GEOIP_DB_PATH.
# Für die lokale, EU-konforme Land-Auflösung ohne externen Transfer (AD-22, EXT-12).
#
# Monatlich ausführen (z.B. Coolify Scheduled Task oder system-cron). Die MMDB ist ein
# Deploy-Artefakt und gehört NICHT ins Git.
#
# Monat überschreibbar: ./scripts/update-geoip-db.sh 2026-05 (falls die Monatsdatei noch nicht released ist)
#
set -euo pipefail
DEST="${GEOIP_DB_PATH:-./data/geoip/dbip-country-lite.mmdb}"
MONTH="${1:-$(date +%Y-%m)}"
URL="https://download.db-ip.com/free/dbip-country-lite-${MONTH}.mmdb.gz"
mkdir -p "$(dirname "$DEST")"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
echo "Lade GeoIP-DB: $URL"
if ! curl -fSL "$URL" -o "$tmp"; then
echo "FEHLER: Download fehlgeschlagen. Ist die Monatsdatei für ${MONTH} schon veröffentlicht?" >&2
echo " Versuche den Vormonat: ./scripts/update-geoip-db.sh JJJJ-MM" >&2
exit 1
fi
gunzip -c "$tmp" > "$DEST"
echo "GeoIP-DB aktualisiert: $DEST"
@@ -0,0 +1,139 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const BASELINE_PATH = ".eslintrc.a11y-baseline.json";
const DEFAULT_SCOPE = ["apps/web/app/**/*.tsx", "packages/ui/src/stories/**/*.tsx"];
function readBaseline(rootDir) {
return JSON.parse(fs.readFileSync(path.join(rootDir, BASELINE_PATH), "utf8"));
}
function baselineScope(baseline) {
if (Array.isArray(baseline.scope)) return baseline.scope;
if (typeof baseline.scope === "string" && baseline.scope.trim().length > 0) return [baseline.scope];
return DEFAULT_SCOPE;
}
function baselineRules(baseline) {
return Object.keys(baseline.rules ?? {}).sort();
}
function runEslint(rootDir, scope, rules) {
const ruleArgs = rules.flatMap((rule) => ["--rule", `${rule}:error`]);
const result = spawnSync("pnpm", ["exec", "eslint", "--format", "json", "--no-ignore", "--ext", ".tsx", ...ruleArgs, ...scope], {
cwd: rootDir,
encoding: "utf8",
maxBuffer: 1024 * 1024 * 20,
});
if (result.error) {
throw result.error;
}
if (![0, 1].includes(result.status)) {
throw new Error(result.stderr || `eslint exited with status ${result.status}`);
}
try {
return JSON.parse(result.stdout || "[]");
} catch (error) {
throw new Error(`failed to parse ESLint JSON output: ${error.message}`);
}
}
function countRuleMessages(eslintResults, rules) {
const counts = Object.fromEntries(rules.map((rule) => [rule, 0]));
const examples = Object.fromEntries(rules.map((rule) => [rule, []]));
for (const result of eslintResults) {
for (const message of result.messages ?? []) {
if (!rules.includes(message.ruleId)) continue;
counts[message.ruleId] += 1;
if (examples[message.ruleId].length < 3) {
examples[message.ruleId].push(`${path.relative(process.cwd(), result.filePath)}:${message.line}:${message.column}`);
}
}
}
return { counts, examples };
}
export function verifyA11yBaseline(rootDir = process.cwd()) {
const baseline = readBaseline(rootDir);
const scope = baselineScope(baseline);
const rules = baselineRules(baseline);
const eslintResults = runEslint(rootDir, scope, rules);
const { counts, examples } = countRuleMessages(eslintResults, rules);
const failures = [];
const observations = [];
const passes = [];
for (const rule of rules) {
const expected = Number(baseline.rules[rule]?.violations ?? 0);
const actual = counts[rule] ?? 0;
if (actual > expected) {
failures.push(`${rule}: ${actual} violation(s), baseline allows ${expected}`);
continue;
}
if (actual < expected) {
observations.push(`${rule}: ${actual} violation(s), below baseline ${expected}; update ${BASELINE_PATH} when ratcheting`);
} else {
passes.push(`${rule}: ${actual} violation(s) matches baseline`);
}
}
const totalExpected = Number(baseline.totalViolations ?? 0);
const totalActual = Object.values(counts).reduce((sum, count) => sum + count, 0);
if (totalActual > totalExpected) {
failures.push(`totalViolations: ${totalActual}, baseline allows ${totalExpected}`);
} else if (totalActual < totalExpected) {
observations.push(`totalViolations: ${totalActual}, below baseline ${totalExpected}; update ${BASELINE_PATH} when ratcheting`);
} else {
passes.push(`totalViolations: ${totalActual} matches baseline`);
}
return { passes, observations, failures, counts, examples, scope };
}
export function formatA11yBaselineResult(result) {
const status = result.failures.length > 0 ? "FAIL" : "PASS";
const lines = [
`${status} accessibility baseline verification`,
`Scope: ${result.scope.join(", ")}`,
...Object.entries(result.counts).map(([rule, count]) => `COUNT ${rule}: ${count}`),
...result.passes.map((pass) => `PASS ${pass}`),
...result.observations.map((observation) => `OBSERVE ${observation}`),
...result.failures.map((failure) => `FAIL ${failure}`),
];
if (result.failures.length > 0) {
for (const [rule, examples] of Object.entries(result.examples)) {
if (examples.length > 0) {
lines.push(`EXAMPLE ${rule}: ${examples.join(", ")}`);
}
}
}
lines.push(`Summary: ${result.passes.length} passed, ${result.observations.length} observed, ${result.failures.length} failed`, "");
return lines.join("\n");
}
const isCli = process.argv[1] && path.resolve(process.argv[1]) === new URL(import.meta.url).pathname;
if (isCli) {
try {
const result = verifyA11yBaseline(process.cwd());
const output = formatA11yBaselineResult(result);
if (result.failures.length > 0) {
process.stderr.write(output);
process.exitCode = 1;
} else {
process.stdout.write(output);
}
} catch (error) {
process.stderr.write(`FAIL accessibility baseline verification\n${error.message}\n`);
process.exitCode = 1;
}
}
@@ -0,0 +1,452 @@
#!/usr/bin/env node
// Anti-regression gate for the static accessibility test suite.
//
// We enforce a minimum number of `*.a11y.test.tsx` / `*.axe.test.tsx` files
// in the web app.
// Removing a11y coverage is a deliberate act and must show up in code review.
//
// Pair with `scripts/verify-test-density.mjs` — that script enforces a
// per-workspace ratio of test files to source files but does not care
// about test type. This script keeps the a11y class of tests from
// silently dropping to zero.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
export const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
export const componentSearchRoot = path.join(repoRoot, "apps/web/app");
export const e2eSearchRoot = path.join(repoRoot, "tests/e2e");
export const MIN_COMPONENT_A11Y_TESTS = 12;
export const MIN_E2E_A11Y_SPECS = 7;
export const REQUIRED_E2E_FLOW_ASSERTIONS = {
"registration-account-dashboard": {
file: "tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"E2E_ALLOW_REGISTRATION_CREATE",
"E2E_ALLOW_ACCOUNT_CREATE",
"E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE",
"guards seeded tenant account membership before dashboard and account smoke",
"replays new-account login redirects and returns to the dashboard on mobile",
"can create a tenant account and reach its dashboard when explicitly enabled",
"can onboard a registered user with no tenant through account creation when explicitly enabled",
"can register a new user and stop at the email verification state when explicitly enabled",
"E2E_REGISTRATION_EXPECTS_EMAIL_VERIFICATION",
"zeroTenantAccountStatePattern",
"/login?redirectTo=",
"[data-ui-id=\"ui.account.create.form\"]",
"failOnStripeNetworkRequests(",
"tenantSettingsSubscriptionPath(",
"[data-ui-id=\"ui.subscription.page\"]",
"[data-ui-id=\"ui.subscription.billing.portal\"]",
],
},
"plugin-settings": {
file: "tests/e2e/plugin-settings.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"lifecycle controls submit through a non-mutating interception contract",
"lifecycle errors stay announced and keep controls retryable",
"lifecycle controls persist enable and disable state for the seeded lifecycle plugin",
"app.plugins.use users cannot submit forged lifecycle mutations without manage permission",
"settings iframe exposes sandbox and CSP contract when available",
"settings iframe persists values across reload when write slice is enabled",
"[data-ui-id=\"ui.plugins.lifecycle.form\"]",
"page.waitForURL(/\\/unauthorized\\/app\\.plugins\\.manage",
"[data-ui-id=\"ui.plugins.settings.frame\"]",
],
},
"tenant-api-docs-logs": {
file: "tests/e2e/tenant-api-docs-logs.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
},
"tenant-api-keys": {
file: "tests/e2e/tenant-api-keys.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"opens create form without mobile overflow or critical a11y violations",
"submits create contract with alias, entity permissions, and csrf without mutating data",
"E2E_TENANT_API_KEYS_PATH",
"interceptCurrentRoutePost(",
'form:has(input[name="action"][value="create"])',
'input[name="alias"]',
'input[name="entities[]"]',
],
},
"tenant-audit-logs": {
file: "tests/e2e/tenant-audit-logs.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"keeps audit logs usable at",
"keeps audit event filters and table reachable on mobile",
"compactMobileViewport",
"[data-ui-id=\"ui.settings.logs.page\"]",
"[data-ui-id=\"ui.settings.logs.filters\"]",
"[data-ui-id=\"ui.settings.logs.table\"]",
"[data-ui-id=\"ui.settings.logs.events.table\"]",
],
},
"tenant-audit-export": {
file: "tests/e2e/tenant-audit-export.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"keeps compliance export filters usable at",
"compactMobileViewport",
"[data-ui-id=\"ui.settings.auditExport.page\"]",
"[data-ui-id=\"ui.settings.auditExport.form\"]",
"[data-ui-id=\"ui.settings.auditExport.downloadCsv\"]",
"[data-ui-id=\"ui.settings.auditExport.downloadPdf\"]",
],
},
"privacy-self-service": {
file: "tests/e2e/privacy-self-service.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders export and deletion controls without mobile overflow or critical a11y violations",
"submits export request with intent and csrf without mutating data",
"submits deletion request with confirmation, intent and csrf without mutating data",
"interceptCurrentRoutePost(",
"[data-ui-id=\"ui.settings.privacy.page\"]",
"[data-ui-id=\"ui.settings.privacy.export.form\"]",
"[data-ui-id=\"ui.settings.privacy.delete.form\"]",
],
},
"profile-security": {
file: "tests/e2e/profile-security.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders security, MFA, session, and danger controls without mobile overflow or critical a11y violations",
"submits password change contract with csrf without mutating data",
"submits MFA toggle contract with csrf without changing enrollment state",
"E2E_TENANT_SETTINGS_PROFILE_PATH",
"interceptCurrentRoutePost(",
"[data-ui-id=\"ui.settings.profile.page\"]",
"[data-ui-id=\"ui.settings.profile.mfa.panel\"]",
"[data-ui-id=\"ui.settings.profile.password.form\"]",
"[data-ui-id=\"ui.settings.profile.mfa.toggle.form\"]",
"[data-ui-id=\"ui.settings.profile.session.panel\"]",
"[data-ui-id=\"ui.settings.profile.danger.delete\"]",
],
},
"tenant-members": {
file: "tests/e2e/tenant-members.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders members table at",
"submits new member invite contract with csrf without mutating data",
"compactMobileViewport",
"interceptCurrentRoutePost(",
"[data-ui-id=\"ui.settings.members.page\"]",
"[data-ui-id=\"ui.settings.members.table\"]",
"[data-ui-id=\"ui.settings.members.new.form\"]",
],
},
"tenant-notifications-settings": {
file: "tests/e2e/tenant-notifications-settings.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders notification categories without mobile overflow or critical a11y violations",
"submits preference update contract with csrf without mutating data",
"interceptCurrentRoutePost(",
"[data-ui-id=\"ui.settings.notifications.page\"]",
"ui.settings.notifications.category.${category}",
"[data-ui-id=\"ui.settings.notifications.preference.team.email\"]",
],
},
"tenant-chatbot-settings": {
file: "tests/e2e/tenant-chatbot-settings.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders access controls without mobile overflow or critical a11y violations",
"submits tenant default access contract with csrf without mutating data",
"interceptCurrentRoutePost(",
"[data-ui-id=\"ui.settings.chatbot.page\"]",
"[data-ui-id=\"ui.settings.chatbot.master.form\"]",
"[data-ui-id=\"ui.settings.chatbot.defaults.form\"]",
"[data-ui-id=\"ui.settings.chatbot.providers\"]",
],
},
"tenant-ai-usage": {
file: "tests/e2e/ai-usage.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders quota and forecast without mobile overflow or critical a11y violations",
"deriveAiUsagePath(",
"getByRole(\"progressbar\"",
"getByRole(\"heading\"",
"/settings/ai-usage",
],
},
"workflow-builder-run": {
file: "tests/e2e/workflow-builder-run.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"builder stays usable on mobile",
"run dropdown stays keyboard reachable without starting execution",
"manual run contract stays usable on mobile",
"stream run waiting-block continuation stays usable on mobile",
"manual run result deep-links to execution details on mobile when explicitly enabled",
"manual run reaches expected execution status when outbox-drain readiness is required",
"E2E_ALLOW_WORKFLOW_RUN",
"requireWorkflowOutboxDrainReadiness(",
"waitForWorkflowExecutionTerminal(",
"[data-ui-id=\"ui.workflows.builder.canvas\"]",
"[data-ui-id=\"ui.workflows.run.manual.form\"]",
"[data-ui-id=\"ui.workflows.run.stream.progress\"]",
"[data-ui-id=\"ui.workflows.run.manual.viewExecutionFlow\"]",
],
},
"admin-email-sequences": {
file: "tests/e2e/admin-email-sequences.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"renders seeded sequence list, detail, and stats surfaces on mobile",
"toggle lifecycle form posts intent without mutating the seeded sequence",
"E2E_ADMIN_EMAIL_SEQUENCES_PATH",
"seq_e2e_customer_welcome",
"lead.converted_to_customer",
"interceptCurrentRoutePost(",
"postDataIncludesField(submittedPostData.value, \"intent\", /^toggle$/u)",
],
},
"portal-management": {
file: "tests/e2e/portal-management.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"portal management pages have no mobile overflow or critical a11y violations",
"E2E_TENANT_PORTAL_ANALYTICS_PATH",
"E2E_TENANT_PORTAL_SETTINGS_PATH",
"portalManagementPaths",
"skipOrFailWhenE2eFixtureMissing(",
],
},
"readiness-golden-path": {
file: "tests/e2e/readiness-golden-path.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"navigates authenticated mobile readiness surfaces without mutations",
"failOnStripeNetworkRequests(",
"[data-ui-id=\"ui.subscription.checkout.page\"]",
"[data-ui-id=\"ui.plugins.page\"]",
"[data-ui-id=\"ui.workflows.builder.canvas\"]",
"[data-ui-id=\"ui.workflows.run.manual.form\"]",
],
},
"subscription-smoke": {
file: "tests/e2e/subscription-smoke.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"[data-ui-id=\"ui.subscription.checkout.submit\"]",
"[data-ui-id=\"ui.subscription.checkout.success.page\"]",
"[data-ui-id=\"ui.subscription.billing.portal\"]",
"[data-ui-id=\"ui.subscription.product.cancel.dialog\"]",
"submits cancellation action contract with CSRF when cancellation controls are rendered",
"opens cancellation confirmation by keyboard and dismisses it with Escape without posting",
"reconciles checkout success state so it persists across reload and re-login",
"failOnStripeNetworkRequests(",
],
},
"mobile-authenticated-shell": {
file: "tests/e2e/mobile-authenticated-shell.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"traps keyboard focus inside the mobile sidebar and returns focus to the trigger",
"mobile command palette focuses search and closes with Escape",
"mobile more menu exposes profile and sign-out contracts without submitting",
"skip link moves keyboard focus to the main app region",
"signs out from the mobile more menu and protects the dashboard",
"[data-ui-id=\"ui.action.shell.sidebarTrigger\"]",
"[data-ui-id=\"ui.surface.commandPalette.portal\"]",
"skip-to-content",
],
},
"authenticated-mobile-locale": {
file: "tests/e2e/authenticated-locale.mobile.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"persists an authenticated locale switch across mobile shell navigation",
"await page.reload({ waitUntil: \"domcontentloaded\" });",
"tenantSettingsProfilePath(dashboardPath)",
"restoreLocale(page, initialLocale)",
"[data-ui-id=\"ui.nav.app.dashboard\"]",
"[data-ui-id=\"ui.nav.app.settings\"]",
],
},
"public-auth": {
file: "tests/e2e/public-auth.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
},
"auth-locale": {
file: "tests/e2e/auth-locale.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
},
"mobile-public": {
file: "tests/e2e/mobile-public.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"mobile public pages",
"compact mobile public pages",
"landing page fits a narrow mobile viewport",
"not-found page fits a narrow mobile viewport",
"feature-disabled page has no mobile overflow or critical a11y violations",
"public footer selectors open without mobile overflow",
"fits a 320px mobile viewport",
"feature-disabled page handles long labels on compact mobile",
"public footer selectors stay inside a 320px viewport",
"compactMobileViewport",
"\"/privacy-policy\"",
"\"/terms-and-conditions\"",
"\"/401\"",
"\"/403\"",
"\"/500\"",
],
},
"tenant-governance-settings": {
file: "tests/e2e/tenant-governance-settings.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
},
"tenant-roles-permissions-mobile": {
file: "tests/e2e/tenant-roles-permissions.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
},
};
export const REQUIRED_E2E_FLOW_SPECS = Object.fromEntries(Object.entries(REQUIRED_E2E_FLOW_ASSERTIONS).map(([flow, contract]) => [flow, contract.file]));
const ignoredDirs = new Set(["node_modules", "dist", "build", "coverage", ".react-router", "generated", "__generated__"]);
function isComponentA11yTest(name) {
return name.endsWith(".a11y.test.tsx") || name.endsWith(".axe.test.tsx");
}
function isE2eA11ySpec(name) {
return (
name.endsWith(".a11y.spec.ts") ||
name.endsWith(".a11y.spec.tsx") ||
name.endsWith(".mobile-a11y.spec.ts") ||
name.endsWith(".mobile-a11y.spec.tsx") ||
name.endsWith(".desktop-a11y.spec.ts") ||
name.endsWith(".desktop-a11y.spec.tsx")
);
}
function isE2eSpec(name) {
return name.endsWith(".spec.ts") || name.endsWith(".spec.tsx");
}
export function walk(dir, matchesFile, root = repoRoot, found = []) {
if (!fs.existsSync(dir)) return found;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (ignoredDirs.has(entry.name) || entry.name.startsWith(".")) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full, matchesFile, root, found);
continue;
}
if (entry.isFile() && matchesFile(entry.name)) {
found.push(path.relative(root, full));
}
}
return found;
}
export function collectA11yCoverage(root = repoRoot) {
const componentRoot = path.join(root, "apps/web/app");
const e2eRoot = path.join(root, "tests/e2e");
const e2eSpecFiles = walk(e2eRoot, isE2eSpec, root).sort();
return {
componentFiles: walk(componentRoot, isComponentA11yTest, root).sort(),
e2eFiles: walk(e2eRoot, isE2eA11ySpec, root).sort(),
e2eSpecFiles,
e2eSpecContents: Object.fromEntries(e2eSpecFiles.map((file) => [file, fs.readFileSync(path.join(root, file), "utf8")])),
};
}
export function verifyA11yCoverage({
componentFiles,
e2eFiles,
e2eSpecFiles = e2eFiles,
e2eSpecContents = {},
minComponent = MIN_COMPONENT_A11Y_TESTS,
minE2e = MIN_E2E_A11Y_SPECS,
requiredE2eSpecs = REQUIRED_E2E_FLOW_SPECS,
requiredE2eAssertions = REQUIRED_E2E_FLOW_ASSERTIONS,
}) {
const failures = [];
if (componentFiles.length < minComponent) {
failures.push(`expected at least ${minComponent} component a11y test file(s), found ${componentFiles.length}`);
}
if (e2eFiles.length < minE2e) {
failures.push(`expected at least ${minE2e} browser a11y spec file(s), found ${e2eFiles.length}`);
}
const e2eSpecSet = new Set(e2eSpecFiles);
for (const [flow, file] of Object.entries(requiredE2eSpecs)) {
if (!e2eSpecSet.has(file)) {
failures.push(`required e2e flow "${flow}" must be covered by ${file}`);
continue;
}
const assertionContract = requiredE2eAssertions[flow];
if (!assertionContract) {
continue;
}
const content = e2eSpecContents[file] ?? "";
if (assertionContract.axe && !content.includes("expectNoCriticalA11yViolations(")) {
failures.push(`required e2e flow "${flow}" in ${file} must call expectNoCriticalA11yViolations`);
}
if (assertionContract.mobileOverflow && !content.includes("expectNoHorizontalOverflow(")) {
failures.push(`required e2e flow "${flow}" in ${file} must call expectNoHorizontalOverflow`);
}
for (const snippet of assertionContract.requiredSnippets ?? []) {
if (!content.includes(snippet)) {
failures.push(`required e2e flow "${flow}" in ${file} must include ${JSON.stringify(snippet)}`);
}
}
}
return { ok: failures.length === 0, failures };
}
export function main(root = repoRoot) {
const coverage = collectA11yCoverage(root);
for (const f of coverage.componentFiles) console.log(`component-a11y: ${f}`);
for (const f of coverage.e2eFiles) console.log(`e2e-a11y: ${f}`);
console.log(`Found ${coverage.componentFiles.length} component a11y test file(s) (min ${MIN_COMPONENT_A11Y_TESTS}).`);
console.log(`Found ${coverage.e2eFiles.length} browser a11y spec file(s) (min ${MIN_E2E_A11Y_SPECS}).`);
console.log(
`Required browser readiness/a11y specs: ${Object.entries(REQUIRED_E2E_FLOW_SPECS)
.map(([flow, file]) => `${flow}=${file}`)
.join(", ")}.`
);
const result = verifyA11yCoverage(coverage);
if (!result.ok) {
console.error(`FAIL: ${result.failures.join("; ")}.`);
process.exit(1);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
@@ -0,0 +1,396 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { collectA11yCoverage, REQUIRED_E2E_FLOW_ASSERTIONS, verifyA11yCoverage } from "./verify-a11y-coverage.mjs";
const tempRoots = [];
async function createFixtureRoot() {
const root = await mkdtemp(path.join(os.tmpdir(), "a11y-coverage-"));
tempRoots.push(root);
return root;
}
async function writeFixtureFile(root, relativePath) {
const absolutePath = path.join(root, relativePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, "export {};\n", "utf8");
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("verify-a11y-coverage", () => {
it("counts component and browser-level a11y coverage separately", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "apps/web/app/components/Button.a11y.test.tsx");
await writeFixtureFile(root, "apps/web/app/components/Dialog.axe.test.tsx");
await writeFixtureFile(root, "tests/e2e/login.a11y.spec.ts");
await writeFixtureFile(root, "tests/e2e/workflow-engine.mobile-a11y.spec.ts");
expect(collectA11yCoverage(root)).toEqual({
componentFiles: ["apps/web/app/components/Button.a11y.test.tsx", "apps/web/app/components/Dialog.axe.test.tsx"],
e2eFiles: ["tests/e2e/login.a11y.spec.ts", "tests/e2e/workflow-engine.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/login.a11y.spec.ts", "tests/e2e/workflow-engine.mobile-a11y.spec.ts"],
e2eSpecContents: {
"tests/e2e/login.a11y.spec.ts": "export {};\n",
"tests/e2e/workflow-engine.mobile-a11y.spec.ts": "export {};\n",
},
});
});
it("ignores generated, dependency, and non-a11y files", async () => {
const root = await createFixtureRoot();
await writeFixtureFile(root, "apps/web/app/components/Button.test.tsx");
await writeFixtureFile(root, "apps/web/app/node_modules/vendor/Card.a11y.test.tsx");
await writeFixtureFile(root, "apps/web/app/build/Button.a11y.test.tsx");
await writeFixtureFile(root, "tests/e2e/generated/login.a11y.spec.ts");
await writeFixtureFile(root, "tests/e2e/login.spec.ts");
expect(collectA11yCoverage(root)).toEqual({
componentFiles: [],
e2eFiles: [],
e2eSpecFiles: ["tests/e2e/login.spec.ts"],
e2eSpecContents: {
"tests/e2e/login.spec.ts": "export {};\n",
},
});
});
it("fails when either coverage class drops below its minimum", () => {
expect(verifyA11yCoverage({ componentFiles: ["component.a11y.test.tsx"], e2eFiles: [], minComponent: 2, minE2e: 1, requiredE2eSpecs: {} })).toEqual({
ok: false,
failures: ["expected at least 2 component a11y test file(s), found 1", "expected at least 1 browser a11y spec file(s), found 0"],
});
});
it("passes when both coverage classes meet their minima", () => {
expect(verifyA11yCoverage({ componentFiles: ["a", "b"], e2eFiles: ["c"], minComponent: 2, minE2e: 1, requiredE2eSpecs: {} })).toEqual({
ok: true,
failures: [],
});
});
it("requires named browser readiness specs in addition to raw counts", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts", "tests/e2e/subscription-smoke.spec.ts"],
minComponent: 2,
minE2e: 1,
requiredE2eSpecs: {
"registration-account-dashboard": "tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts",
"subscription-smoke": "tests/e2e/subscription-smoke.spec.ts",
"plugin-settings": "tests/e2e/plugin-settings.mobile-a11y.spec.ts",
},
requiredE2eAssertions: {},
})
).toEqual({
ok: false,
failures: ['required e2e flow "plugin-settings" must be covered by tests/e2e/plugin-settings.mobile-a11y.spec.ts'],
});
});
it("requires named browser readiness specs to contain expected a11y assertions", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/public-auth.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/public-auth.mobile-a11y.spec.ts"],
e2eSpecContents: {
"tests/e2e/public-auth.mobile-a11y.spec.ts": "await expectNoHorizontalOverflow(page);",
},
minComponent: 2,
minE2e: 1,
requiredE2eSpecs: {
"public-auth": "tests/e2e/public-auth.mobile-a11y.spec.ts",
},
})
).toEqual({
ok: false,
failures: ['required e2e flow "public-auth" in tests/e2e/public-auth.mobile-a11y.spec.ts must call expectNoCriticalA11yViolations'],
});
});
it("requires named browser readiness specs to contain flow-specific coverage snippets", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts"],
e2eSpecContents: {
"tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts": "await expectNoCriticalA11yViolations(page);\nawait expectNoHorizontalOverflow(page);\n",
},
minComponent: 2,
minE2e: 1,
requiredE2eSpecs: {
"registration-account-dashboard": "tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts",
},
requiredE2eAssertions: {
"registration-account-dashboard": {
file: "tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE",
"guards seeded tenant account membership before dashboard and account smoke",
"replays new-account login redirects and returns to the dashboard on mobile",
"can create a tenant account and reach its dashboard when explicitly enabled",
"can onboard a registered user with no tenant through account creation when explicitly enabled",
"zeroTenantAccountStatePattern",
"/login?redirectTo=",
"[data-ui-id=\"ui.account.create.form\"]",
"tenantSettingsSubscriptionPath(",
"[data-ui-id=\"ui.subscription.page\"]",
],
},
},
})
).toEqual({
ok: false,
failures: [
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "E2E_ALLOW_NO_TENANT_ACCOUNT_CREATE"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "guards seeded tenant account membership before dashboard and account smoke"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "replays new-account login redirects and returns to the dashboard on mobile"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "can create a tenant account and reach its dashboard when explicitly enabled"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "can onboard a registered user with no tenant through account creation when explicitly enabled"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "zeroTenantAccountStatePattern"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "/login?redirectTo="',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.account.create.form\\"]"',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "tenantSettingsSubscriptionPath("',
'required e2e flow "registration-account-dashboard" in tests/e2e/registration-account-dashboard.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.subscription.page\\"]"',
],
});
});
it("pins plugin lifecycle and workflow run coverage beyond file-level a11y checks", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/plugin-settings.mobile-a11y.spec.ts", "tests/e2e/workflow-builder-run.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/plugin-settings.mobile-a11y.spec.ts", "tests/e2e/workflow-builder-run.mobile-a11y.spec.ts"],
e2eSpecContents: {
"tests/e2e/plugin-settings.mobile-a11y.spec.ts": "await expectNoCriticalA11yViolations(page);\nawait expectNoHorizontalOverflow(page);\n",
"tests/e2e/workflow-builder-run.mobile-a11y.spec.ts": "await expectNoCriticalA11yViolations(page);\nawait expectNoHorizontalOverflow(page);\n",
},
minComponent: 2,
minE2e: 2,
requiredE2eSpecs: {
"plugin-settings": "tests/e2e/plugin-settings.mobile-a11y.spec.ts",
"workflow-builder-run": "tests/e2e/workflow-builder-run.mobile-a11y.spec.ts",
},
requiredE2eAssertions: {
"plugin-settings": {
file: "tests/e2e/plugin-settings.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"lifecycle controls submit through a non-mutating interception contract",
"lifecycle controls persist enable and disable state for the seeded lifecycle plugin",
"app.plugins.use users cannot submit forged lifecycle mutations without manage permission",
"settings iframe persists values across reload when write slice is enabled",
"[data-ui-id=\"ui.plugins.lifecycle.form\"]",
"page.waitForURL(/\\/unauthorized\\/app\\.plugins\\.manage",
],
},
"workflow-builder-run": {
file: "tests/e2e/workflow-builder-run.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"manual run contract stays usable on mobile",
"manual run result deep-links to execution details on mobile when explicitly enabled",
"manual run reaches expected execution status when outbox-drain readiness is required",
"E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN",
"[data-ui-id=\"ui.workflows.run.manual.form\"]",
"[data-ui-id=\"ui.workflows.run.manual.viewExecutionFlow\"]",
],
},
},
})
).toEqual({
ok: false,
failures: [
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "lifecycle controls submit through a non-mutating interception contract"',
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "lifecycle controls persist enable and disable state for the seeded lifecycle plugin"',
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "app.plugins.use users cannot submit forged lifecycle mutations without manage permission"',
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "settings iframe persists values across reload when write slice is enabled"',
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.plugins.lifecycle.form\\"]"',
'required e2e flow "plugin-settings" in tests/e2e/plugin-settings.mobile-a11y.spec.ts must include "page.waitForURL(/\\\\/unauthorized\\\\/app\\\\.plugins\\\\.manage"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "manual run contract stays usable on mobile"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "manual run result deep-links to execution details on mobile when explicitly enabled"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "manual run reaches expected execution status when outbox-drain readiness is required"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.workflows.run.manual.form\\"]"',
'required e2e flow "workflow-builder-run" in tests/e2e/workflow-builder-run.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.workflows.run.manual.viewExecutionFlow\\"]"',
],
});
});
it("pins readiness golden path coverage across subscription and workflow followthrough", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/readiness-golden-path.mobile-a11y.spec.ts"],
e2eSpecFiles: ["tests/e2e/readiness-golden-path.mobile-a11y.spec.ts"],
e2eSpecContents: {
"tests/e2e/readiness-golden-path.mobile-a11y.spec.ts":
"await expectNoCriticalA11yViolations(page);\nawait expectNoHorizontalOverflow(page);\n",
},
minComponent: 2,
minE2e: 1,
requiredE2eSpecs: {
"readiness-golden-path": "tests/e2e/readiness-golden-path.mobile-a11y.spec.ts",
},
requiredE2eAssertions: {
"readiness-golden-path": {
file: "tests/e2e/readiness-golden-path.mobile-a11y.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"navigates authenticated mobile readiness surfaces without mutations",
"failOnStripeNetworkRequests(",
"[data-ui-id=\"ui.subscription.checkout.page\"]",
"[data-ui-id=\"ui.plugins.page\"]",
"[data-ui-id=\"ui.workflows.builder.canvas\"]",
"[data-ui-id=\"ui.workflows.run.manual.form\"]",
],
},
},
})
).toEqual({
ok: false,
failures: [
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "navigates authenticated mobile readiness surfaces without mutations"',
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "failOnStripeNetworkRequests("',
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.subscription.checkout.page\\"]"',
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.plugins.page\\"]"',
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.workflows.builder.canvas\\"]"',
'required e2e flow "readiness-golden-path" in tests/e2e/readiness-golden-path.mobile-a11y.spec.ts must include "[data-ui-id=\\"ui.workflows.run.manual.form\\"]"',
],
});
});
it("pins authenticated mobile locale persistence to reload, profile shell, restore, and a11y checks", () => {
expect(
verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: ["tests/e2e/authenticated-locale.mobile.spec.ts"],
e2eSpecFiles: ["tests/e2e/authenticated-locale.mobile.spec.ts"],
e2eSpecContents: {
"tests/e2e/authenticated-locale.mobile.spec.ts": "await expectNoHorizontalOverflow(page);\n",
},
minComponent: 2,
minE2e: 1,
requiredE2eSpecs: {
"authenticated-mobile-locale": "tests/e2e/authenticated-locale.mobile.spec.ts",
},
requiredE2eAssertions: {
"authenticated-mobile-locale": {
file: "tests/e2e/authenticated-locale.mobile.spec.ts",
axe: true,
mobileOverflow: true,
requiredSnippets: [
"persists an authenticated locale switch across mobile shell navigation",
"await page.reload({ waitUntil: \"domcontentloaded\" });",
"tenantSettingsProfilePath(dashboardPath)",
"restoreLocale(page, initialLocale)",
"[data-ui-id=\"ui.nav.app.dashboard\"]",
"[data-ui-id=\"ui.nav.app.settings\"]",
],
},
},
})
).toEqual({
ok: false,
failures: [
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must call expectNoCriticalA11yViolations',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "persists an authenticated locale switch across mobile shell navigation"',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "await page.reload({ waitUntil: \\"domcontentloaded\\" });"',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "tenantSettingsProfilePath(dashboardPath)"',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "restoreLocale(page, initialLocale)"',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "[data-ui-id=\\"ui.nav.app.dashboard\\"]"',
'required e2e flow "authenticated-mobile-locale" in tests/e2e/authenticated-locale.mobile.spec.ts must include "[data-ui-id=\\"ui.nav.app.settings\\"]"',
],
});
});
it("pins seeded tenant control-plane mobile/a11y specs as named readiness flows", () => {
const flows = [
"tenant-api-keys",
"tenant-audit-logs",
"tenant-audit-export",
"privacy-self-service",
"profile-security",
"tenant-members",
"tenant-notifications-settings",
"tenant-chatbot-settings",
"tenant-ai-usage",
];
const requiredE2eAssertions = Object.fromEntries(flows.map((flow) => [flow, REQUIRED_E2E_FLOW_ASSERTIONS[flow]]));
const requiredE2eSpecs = Object.fromEntries(flows.map((flow) => [flow, REQUIRED_E2E_FLOW_ASSERTIONS[flow].file]));
const files = Object.values(requiredE2eSpecs);
const contentsWithoutFlowContracts = Object.fromEntries(
files.map((file) => [file, "await expectNoCriticalA11yViolations(page);\nawait expectNoHorizontalOverflow(page);\n"]),
);
for (const flow of flows) {
expect(REQUIRED_E2E_FLOW_ASSERTIONS[flow]).toMatchObject({
file: expect.stringMatching(/^tests\/e2e\/.+\.spec\.ts$/u),
axe: true,
mobileOverflow: true,
});
expect(REQUIRED_E2E_FLOW_ASSERTIONS[flow].requiredSnippets.length).toBeGreaterThan(0);
}
const result = verifyA11yCoverage({
componentFiles: ["a", "b"],
e2eFiles: files,
e2eSpecFiles: files,
e2eSpecContents: contentsWithoutFlowContracts,
minComponent: 2,
minE2e: files.length,
requiredE2eSpecs,
requiredE2eAssertions,
});
expect(result.ok).toBe(false);
expect(result.failures).toEqual(
expect.arrayContaining([
'required e2e flow "tenant-api-keys" in tests/e2e/tenant-api-keys.mobile-a11y.spec.ts must include "opens create form without mobile overflow or critical a11y violations"',
'required e2e flow "tenant-audit-logs" in tests/e2e/tenant-audit-logs.mobile-a11y.spec.ts must include "keeps audit logs usable at"',
'required e2e flow "tenant-audit-export" in tests/e2e/tenant-audit-export.mobile-a11y.spec.ts must include "keeps compliance export filters usable at"',
'required e2e flow "privacy-self-service" in tests/e2e/privacy-self-service.mobile-a11y.spec.ts must include "renders export and deletion controls without mobile overflow or critical a11y violations"',
'required e2e flow "profile-security" in tests/e2e/profile-security.mobile-a11y.spec.ts must include "renders security, MFA, session, and danger controls without mobile overflow or critical a11y violations"',
'required e2e flow "tenant-members" in tests/e2e/tenant-members.mobile-a11y.spec.ts must include "renders members table at"',
'required e2e flow "tenant-notifications-settings" in tests/e2e/tenant-notifications-settings.mobile-a11y.spec.ts must include "renders notification categories without mobile overflow or critical a11y violations"',
'required e2e flow "tenant-chatbot-settings" in tests/e2e/tenant-chatbot-settings.mobile-a11y.spec.ts must include "renders access controls without mobile overflow or critical a11y violations"',
'required e2e flow "tenant-ai-usage" in tests/e2e/ai-usage.mobile-a11y.spec.ts must include "renders quota and forecast without mobile overflow or critical a11y violations"',
]),
);
});
it("pins public mobile overflow and a11y coverage as a named readiness flow", () => {
const flow = REQUIRED_E2E_FLOW_ASSERTIONS["mobile-public"];
expect(flow).toMatchObject({
file: "tests/e2e/mobile-public.spec.ts",
axe: true,
mobileOverflow: true,
});
expect(flow.requiredSnippets).toEqual(
expect.arrayContaining([
"mobile public pages",
"compact mobile public pages",
"feature-disabled page has no mobile overflow or critical a11y violations",
"public footer selectors stay inside a 320px viewport",
"compactMobileViewport",
"\"/privacy-policy\"",
])
);
});
});
@@ -0,0 +1,116 @@
import fs from "node:fs";
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { missingReadinessEnv } from "./run-playwright-a11y.mjs";
const repoRoot = process.cwd();
const readinessWorkflowEnvKeys = missingReadinessEnv({ env: {} });
function readWorkflow(relativePath) {
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
}
describe("a11y workflow install cache", () => {
for (const workflow of [".github/workflows/a11y.yml", ".gitea/workflows/a11y.yml"]) {
it(`${workflow} uses the pnpm store cache before installing`, () => {
const source = readWorkflow(workflow);
const configureStoreIndex = source.indexOf("pnpm config set store-dir .pnpm-store");
const cacheIndex = source.indexOf("uses: actions/cache@v4");
const installIndex = source.indexOf("pnpm install --frozen-lockfile --prefer-offline");
assert.ok(configureStoreIndex > 0, `${workflow} must configure .pnpm-store`);
assert.ok(cacheIndex > configureStoreIndex, `${workflow} must cache .pnpm-store after configuring it`);
assert.ok(source.includes("path: .pnpm-store"), `${workflow} must cache the pnpm store path`);
assert.ok(source.includes("key: ${{ runner.os }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}"), `${workflow} must key cache by pnpm lockfile`);
assert.ok(installIndex > cacheIndex, `${workflow} must install with --prefer-offline after cache restore`);
});
}
it(".github/workflows/a11y.yml cancels superseded PR runs without path-filtering required checks", () => {
const source = readWorkflow(".github/workflows/a11y.yml");
assert.match(source, /^concurrency:\s*$/m, "GitHub a11y workflow must cancel superseded runs");
assert.ok(
source.includes("group: a11y-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}"),
"GitHub a11y workflow must scope concurrency by workflow and PR/ref",
);
assert.match(source, /^\s+cancel-in-progress:\s*true\s*$/m, "GitHub a11y workflow must cancel in-progress duplicates");
assert.doesNotMatch(source, /^\s+paths:\s*$/m, "GitHub required a11y checks must not be skipped by path filters");
assert.doesNotMatch(source, /^\s+paths-ignore:\s*$/m, "GitHub required a11y checks must not be skipped by path-ignore filters");
});
});
describe("e2e readiness workflows", () => {
for (const workflow of [".github/workflows/e2e-readiness.yml", ".gitea/workflows/e2e-readiness.yml"]) {
it(`${workflow} runs the browser readiness suite in fail-closed mode`, () => {
const source = readWorkflow(workflow);
assert.match(source, /^ workflow_dispatch:\s*$/m, `${workflow} must support manual operator dispatch`);
assert.match(source, /^ schedule:\s*$/m, `${workflow} must run on a schedule`);
assert.ok(source.includes("pnpm config set store-dir .pnpm-store"), `${workflow} must configure the pnpm store`);
assert.ok(source.includes("path: .cache/ms-playwright"), `${workflow} must cache Playwright browsers`);
assert.ok(source.includes("pnpm install --frozen-lockfile --prefer-offline"), `${workflow} must install deterministically`);
assert.ok(source.includes("pnpm exec playwright install --with-deps chromium webkit"), `${workflow} must install every browser used by E2E_BROWSER_MATRIX=1`);
assert.ok(source.includes("pnpm run verify:e2e-a11y"), `${workflow} must run the strict Playwright wrapper`);
assert.ok(source.includes("plugin_lifecycle_mutation:"), `${workflow} must expose the plugin lifecycle mutation manual dispatch opt-in`);
assert.ok(source.includes("plugin_settings_write:"), `${workflow} must expose the plugin settings write manual dispatch opt-in`);
assert.ok(source.includes("workflow_outbox_drain:"), `${workflow} must expose the workflow outbox-drain manual dispatch opt-in`);
assert.ok(source.includes("workflow_webhook_run:"), `${workflow} must expose the real workflow webhook manual dispatch opt-in`);
assert.ok(source.includes("workflow_webhook_target_url:"), `${workflow} must expose the real workflow webhook target URL input`);
assert.ok(source.includes("subscription_success:"), `${workflow} must expose the Stripe checkout success manual dispatch opt-in`);
assert.ok(source.includes("subscription_success_path:"), `${workflow} must expose the Stripe checkout success return path input`);
assert.ok(source.includes("Apply manual operator E2E opt-ins"), `${workflow} must apply manual operator opt-ins before running Playwright`);
for (const requiredEnv of [
'REQUIRE_E2E_A11Y: "1"',
'REQUIRE_AUTH_E2E: "1"',
'REQUIRE_E2E_READINESS: "1"',
'E2E_A11Y_STRICT: "1"',
'E2E_BROWSER_MATRIX: "1"',
'E2E_ALLOW_WORKFLOW_RUN: "1"',
"E2E_TENANT_EMAIL: ${{ secrets.E2E_TENANT_EMAIL }}",
"E2E_TENANT_PASSWORD: ${{ secrets.E2E_TENANT_PASSWORD }}",
"E2E_PLUGIN_READONLY_EMAIL: ${{ secrets.E2E_PLUGIN_READONLY_EMAIL }}",
"E2E_PLUGIN_READONLY_PASSWORD: ${{ secrets.E2E_PLUGIN_READONLY_PASSWORD }}",
"E2E_TENANT_API_DOCS_PATH: /e2e-readiness/settings/api/docs",
"E2E_TENANT_API_LOGS_PATH: /e2e-readiness/settings/api/logs",
"E2E_PLUGIN_SETTINGS_PATH: /e2e-readiness/settings/plugins",
"E2E_WORKFLOW_ENGINE_PATH: /e2e-readiness/workflow-engine/workflows",
"E2E_WORKFLOW_RUN_API_PATH: /e2e-readiness/workflow-engine/workflows/wf_123/run/api",
"E2E_WORKFLOW_WEBHOOK_BUILDER_PATH: /e2e-readiness/workflow-engine/workflows/wf_webhook_123",
"E2E_WORKFLOW_WEBHOOK_RUN_MANUAL_PATH: /e2e-readiness/workflow-engine/workflows/wf_webhook_123/run/manual",
"PLUGIN_BINARY_BASE_PATH: /tmp/e2e-plugins",
]) {
assert.ok(source.includes(requiredEnv), `${workflow} missing ${requiredEnv}`);
}
for (const optInExport of [
"E2E_ALLOW_PLUGIN_LIFECYCLE_MUTATION=1",
"E2E_ALLOW_PLUGIN_SETTINGS_WRITE=1",
"E2E_REQUIRE_WORKFLOW_OUTBOX_DRAIN=1",
"E2E_ALLOW_WORKFLOW_WEBHOOK_RUN=1",
"E2E_WORKFLOW_WEBHOOK_TARGET_URL=%s",
"E2E_REQUIRE_SUBSCRIPTION_SUCCESS=1",
"E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH=%s",
]) {
assert.ok(source.includes(optInExport), `${workflow} must export ${optInExport} for manual dispatch`);
}
for (const requiredKey of readinessWorkflowEnvKeys) {
assert.match(source, new RegExp(`^\\s+${requiredKey}:\\s+`, "m"), `${workflow} missing strict readiness env key ${requiredKey}`);
}
assert.doesNotMatch(source, /SKIP_E2E_A11Y:\s*"?1"?/, `${workflow} must not silently skip the browser suite`);
assert.ok(source.includes("path: test-results/"), `${workflow} must upload Playwright traces`);
});
}
it("the Gitea readiness workflow uses the self-hosted runner and host resolver", () => {
const source = readWorkflow(".gitea/workflows/e2e-readiness.yml");
assert.ok(source.includes("runs-on: toolctrl-ci"));
assert.ok(source.includes("TOOLCTRL_GITEA_HOST_IP"));
assert.ok(source.includes("git.tool-ctrl.de"));
});
});
@@ -0,0 +1,175 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { markFailedBaselineGates } from "./baseline-gate-exit.mjs";
import { spawn } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const reportPath = path.join(repoRoot, ".tc/baselines/api-auth-enforcement/baseline-health.md");
const authCentralResolverFile = "apps/web/app/utils/services/apiService.ts";
export const gates = [
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
{ id: "vitest-app", command: "pnpm", args: ["exec", "vitest", "run", "apps/web/app/"] },
{ id: "api-auth-surface", command: "node", args: ["scripts/verify-api-auth-surface.mjs", "--json"] },
];
function shellCommand({ command, args }) {
return [command, ...args].join(" ");
}
function outputFingerprint(output) {
const normalized = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-40)
.join("\n");
const hash = createHash("sha256").update(output).digest("hex").slice(0, 12);
return {
hash,
excerpt: normalized.slice(0, 900) || "(no output)",
};
}
function parseNumber(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function countTypeScriptFailures(output) {
return (output.match(/error TS\d+/g) ?? []).length;
}
function countEslintFailures(output) {
const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i);
if (problemsMatch) return parseNumber(problemsMatch[2]);
const compactMatch = output.match(/(\d+)\s+errors?/i);
if (compactMatch) return parseNumber(compactMatch[1]);
return 0;
}
function countVitestFailures(output) {
const failedTestMatch = output.match(/Tests\s+\d+\s+failed/i);
if (failedTestMatch) {
const countMatch = failedTestMatch[0].match(/Tests\s+(\d+)\s+failed/i);
return countMatch ? parseNumber(countMatch[1]) : 1;
}
const failedFilesMatch = output.match(/Test Files\s+(\d+)\s+failed/i);
return failedFilesMatch ? parseNumber(failedFilesMatch[1]) : 0;
}
function countAuthSurfaceFailures(stdout, output) {
try {
const inventory = JSON.parse(stdout);
return (inventory.directValidateApiKeyCallsites ?? []).filter((entry) => entry.file !== authCentralResolverFile).length;
} catch {
return output.trim() ? 1 : 0;
}
}
export function failureCount(gateId, stdout, stderr) {
const output = `${stdout}\n${stderr}`;
if (gateId === "typecheck") return countTypeScriptFailures(output);
if (gateId === "lint") return countEslintFailures(output);
if (gateId === "vitest-app") return countVitestFailures(output);
if (gateId === "api-auth-surface") return countAuthSurfaceFailures(stdout, output);
return 0;
}
function runGate(gate) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(gate.command, gate.args, {
cwd: repoRoot,
env: { ...process.env, CI: "1" },
shell: false,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
stderr += `\n${error.message}`;
});
child.on("close", (code) => {
const output = `${stdout}\n${stderr}`;
resolve({
id: gate.id,
command: shellCommand(gate),
exitCode: code ?? 1,
durationMs: Date.now() - startedAt,
failureCount: failureCount(gate.id, stdout, stderr),
fingerprint: outputFingerprint(output),
});
});
});
}
export function renderReport(results) {
const lines = [
"# API Auth Enforcement Baseline Health",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This report captures non-E2E baseline gates for API Auth Enforcement baseline. The runner exits 0 when every gate was executed and this report was written, even if individual gates fail.",
"",
];
for (const result of results) {
lines.push(
`## ${result.id}`,
"",
`- Command: \`${result.command}\``,
`- Exitcode: ${result.exitCode}`,
`- Failure Count: ${result.failureCount}`,
`- Duration: ${result.durationMs}ms`,
`- Output Fingerprint: ${result.fingerprint.hash}`,
"",
"```text",
result.fingerprint.excerpt,
"```",
"",
);
}
return `${lines.join("\n")}\n`;
}
async function main() {
const results = [];
for (const gate of gates) {
console.log(`Running ${gate.id}: ${shellCommand(gate)}`);
results.push(await runGate(gate));
}
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, renderReport(results), "utf8");
console.log(`Wrote ${path.relative(repoRoot, reportPath)}`);
markFailedBaselineGates(results);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(async (error) => {
console.error(error);
try {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `# API Auth Enforcement Baseline Health\n\nRunner failed before report completion.\n\n\`\`\`text\n${error?.stack ?? error}\n\`\`\`\n`, "utf8");
} finally {
process.exit(1);
}
});
}
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { failureCount, gates, renderReport } from "./verify-api-auth-enforced-baseline.mjs";
describe("verify-api-auth-enforced-baseline", () => {
it("defines the expected non-E2E gate commands", () => {
assert.deepEqual(
gates.map((gate) => [gate.id, gate.command, gate.args]),
[
["typecheck", "pnpm", ["run", "typecheck"]],
["lint", "pnpm", ["run", "lint"]],
["vitest-app", "pnpm", ["exec", "vitest", "run", "apps/web/app/"]],
["api-auth-surface", "node", ["scripts/verify-api-auth-surface.mjs", "--json"]],
],
);
const commandText = gates.map((gate) => [gate.command, ...gate.args].join(" ")).join("\n");
assert.doesNotMatch(commandText, /playwright|cypress|browser|test:e2e/i);
});
it("derives failure counts from non-zero gate output", () => {
assert.equal(failureCount("typecheck", "src/a.ts(1,1): error TS2322: wrong\nerror TS7006", ""), 2);
assert.equal(failureCount("lint", "✖ 3 problems (2 errors, 1 warning)", ""), 2);
assert.equal(failureCount("lint", "2 errors and 0 warnings potentially fixable", ""), 2);
assert.equal(failureCount("vitest-app", "Tests 4 failed | 10 passed", ""), 4);
assert.equal(failureCount("vitest-app", "Test Files 3 failed | 20 passed", ""), 3);
assert.equal(
failureCount(
"api-auth-surface",
JSON.stringify({
directValidateApiKeyCallsites: [
{ file: "apps/api/src/app.ts" },
{ file: "apps/web/app/utils/services/apiService.ts" },
],
}),
"",
),
1,
);
assert.equal(failureCount("api-auth-surface", "not json", ""), 1);
});
it("renders API Auth Enforcement report sections with exitcodes, failure counts and fingerprints", () => {
const report = renderReport([
{
id: "typecheck",
command: "pnpm run typecheck",
exitCode: 2,
durationMs: 123,
failureCount: 5,
fingerprint: { hash: "abc123", excerpt: "error TS2322" },
},
]);
assert.match(report, /# API Auth Enforcement Baseline Health/);
assert.match(report, /API Auth Enforcement\ baseline/);
assert.doesNotMatch(report, /API Auth Routing Baseline Health/);
assert.match(report, /## typecheck/);
assert.match(report, /Exitcode: 2/);
assert.match(report, /Failure Count: 5/);
assert.match(report, /Output Fingerprint: abc123/);
});
});
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const reportPath = path.join(repoRoot, ".tc/baselines/api-auth-hardening/baseline-health.md");
const authCentralResolverFile = "apps/web/app/utils/services/apiService.ts";
export const gates = [
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
{ id: "vitest-app", command: "pnpm", args: ["exec", "vitest", "run", "apps/web/app/"] },
{ id: "api-auth-surface", command: "node", args: ["scripts/verify-api-auth-surface.mjs", "--json"] },
];
function shellCommand({ command, args }) {
return [command, ...args].join(" ");
}
function outputFingerprint(output) {
const normalized = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-40)
.join("\n");
const hash = createHash("sha256").update(output).digest("hex").slice(0, 12);
return {
hash,
excerpt: normalized.slice(0, 900) || "(no output)",
};
}
function parseNumber(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function countTypeScriptFailures(output) {
return (output.match(/error TS\d+/g) ?? []).length;
}
function countEslintFailures(output) {
const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i);
if (problemsMatch) return parseNumber(problemsMatch[2]);
const compactMatch = output.match(/(\d+)\s+errors?/i);
if (compactMatch) return parseNumber(compactMatch[1]);
return 0;
}
function countVitestFailures(output) {
const failedTestMatch = output.match(/Tests\s+\d+\s+failed/i);
if (failedTestMatch) {
const countMatch = failedTestMatch[0].match(/Tests\s+(\d+)\s+failed/i);
return countMatch ? parseNumber(countMatch[1]) : 1;
}
const failedFilesMatch = output.match(/Test Files\s+(\d+)\s+failed/i);
return failedFilesMatch ? parseNumber(failedFilesMatch[1]) : 0;
}
function countAuthSurfaceFailures(stdout, output) {
try {
const inventory = JSON.parse(stdout);
return (inventory.directValidateApiKeyCallsites ?? []).filter((entry) => entry.file !== authCentralResolverFile).length;
} catch {
return output.trim() ? 1 : 0;
}
}
export function failureCount(gateId, stdout, stderr) {
const output = `${stdout}\n${stderr}`;
if (gateId === "typecheck") return countTypeScriptFailures(output);
if (gateId === "lint") return countEslintFailures(output);
if (gateId === "vitest-app") return countVitestFailures(output);
if (gateId === "api-auth-surface") return countAuthSurfaceFailures(stdout, output);
return 0;
}
function runGate(gate) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(gate.command, gate.args, {
cwd: repoRoot,
env: { ...process.env, CI: "1" },
shell: false,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
stderr += `\n${error.message}`;
});
child.on("close", (code) => {
const output = `${stdout}\n${stderr}`;
resolve({
id: gate.id,
command: shellCommand(gate),
exitCode: code ?? 1,
durationMs: Date.now() - startedAt,
failureCount: failureCount(gate.id, stdout, stderr),
fingerprint: outputFingerprint(output),
});
});
});
}
export function renderReport(results) {
const lines = [
"# API Auth Hardening Baseline Health",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This report captures non-E2E baseline gates for API Auth Hardening baseline. The runner exits 0 when every gate was executed and this report was written, even if individual gates fail.",
"",
];
for (const result of results) {
lines.push(
`## ${result.id}`,
"",
`- Command: \`${result.command}\``,
`- Exitcode: ${result.exitCode}`,
`- Failure Count: ${result.failureCount}`,
`- Duration: ${result.durationMs}ms`,
`- Output Fingerprint: ${result.fingerprint.hash}`,
"",
"```text",
result.fingerprint.excerpt,
"```",
"",
);
}
return `${lines.join("\n")}\n`;
}
async function main() {
const results = [];
for (const gate of gates) {
console.log(`Running ${gate.id}: ${shellCommand(gate)}`);
results.push(await runGate(gate));
}
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, renderReport(results), "utf8");
console.log(`Wrote ${path.relative(repoRoot, reportPath)}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(async (error) => {
console.error(error);
try {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `# API Auth Hardening Baseline Health\n\nRunner failed before report completion.\n\n\`\`\`text\n${error?.stack ?? error}\n\`\`\`\n`, "utf8");
} finally {
process.exit(1);
}
});
}
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { failureCount, gates, renderReport } from "./verify-api-auth-hardening-baseline.mjs";
describe("verify-api-auth-hardening-baseline", () => {
it("defines the expected non-E2E gate commands", () => {
assert.deepEqual(
gates.map((gate) => [gate.id, gate.command, gate.args]),
[
["typecheck", "pnpm", ["run", "typecheck"]],
["lint", "pnpm", ["run", "lint"]],
["vitest-app", "pnpm", ["exec", "vitest", "run", "apps/web/app/"]],
["api-auth-surface", "node", ["scripts/verify-api-auth-surface.mjs", "--json"]],
],
);
const commandText = gates.map((gate) => [gate.command, ...gate.args].join(" ")).join("\n");
assert.doesNotMatch(commandText, /playwright|cypress|browser|test:e2e/i);
});
it("derives failure counts from non-zero gate output", () => {
assert.equal(failureCount("typecheck", "src/a.ts(1,1): error TS2322: wrong\nerror TS7006", ""), 2);
assert.equal(failureCount("lint", "✖ 3 problems (2 errors, 1 warning)", ""), 2);
assert.equal(failureCount("lint", "2 errors and 0 warnings potentially fixable", ""), 2);
assert.equal(failureCount("vitest-app", "Tests 4 failed | 10 passed", ""), 4);
assert.equal(failureCount("vitest-app", "Test Files 3 failed | 20 passed", ""), 3);
assert.equal(
failureCount(
"api-auth-surface",
JSON.stringify({
directValidateApiKeyCallsites: [
{ file: "apps/api/src/app.ts" },
{ file: "apps/web/app/utils/services/apiService.ts" },
],
}),
"",
),
1,
);
assert.equal(failureCount("api-auth-surface", "not json", ""), 1);
});
it("renders API Auth Hardening report sections with exitcodes, failure counts and fingerprints", () => {
const report = renderReport([
{
id: "typecheck",
command: "pnpm run typecheck",
exitCode: 2,
durationMs: 123,
failureCount: 5,
fingerprint: { hash: "abc123", excerpt: "error TS2322" },
},
]);
assert.match(report, /# API Auth Hardening Baseline Health/);
assert.match(report, /API Auth Hardening\ baseline/);
assert.doesNotMatch(report, /API Auth Routing Baseline Health/);
assert.match(report, /## typecheck/);
assert.match(report, /Exitcode: 2/);
assert.match(report, /Failure Count: 5/);
assert.match(report, /Output Fingerprint: abc123/);
});
});
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const reportPath = path.join(repoRoot, ".tc/baselines/api-auth-routing/baseline-health.md");
const authCentralResolverFile = "apps/web/app/utils/services/apiService.ts";
export const gates = [
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
{ id: "vitest-app", command: "pnpm", args: ["exec", "vitest", "run", "apps/web/app/"] },
{ id: "api-auth-surface", command: "node", args: ["scripts/verify-api-auth-surface.mjs", "--json"] },
];
function shellCommand({ command, args }) {
return [command, ...args].join(" ");
}
function outputFingerprint(output) {
const normalized = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-40)
.join("\n");
const hash = createHash("sha256").update(output).digest("hex").slice(0, 12);
return {
hash,
excerpt: normalized.slice(0, 900) || "(no output)",
};
}
function parseNumber(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function countTypeScriptFailures(output) {
return (output.match(/error TS\d+/g) ?? []).length;
}
function countEslintFailures(output) {
const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i);
if (problemsMatch) return parseNumber(problemsMatch[2]);
const compactMatch = output.match(/(\d+)\s+errors?/i);
if (compactMatch) return parseNumber(compactMatch[1]);
return 0;
}
function countVitestFailures(output) {
const failedTestMatch = output.match(/Tests\s+\d+\s+failed/i);
if (failedTestMatch) {
const countMatch = failedTestMatch[0].match(/Tests\s+(\d+)\s+failed/i);
return countMatch ? parseNumber(countMatch[1]) : 1;
}
const failedFilesMatch = output.match(/Test Files\s+(\d+)\s+failed/i);
return failedFilesMatch ? parseNumber(failedFilesMatch[1]) : 0;
}
function countAuthSurfaceFailures(stdout, output) {
try {
const inventory = JSON.parse(stdout);
return (inventory.directValidateApiKeyCallsites ?? []).filter((entry) => entry.file !== authCentralResolverFile).length;
} catch {
return output.trim() ? 1 : 0;
}
}
export function failureCount(gateId, stdout, stderr) {
const output = `${stdout}\n${stderr}`;
if (gateId === "typecheck") return countTypeScriptFailures(output);
if (gateId === "lint") return countEslintFailures(output);
if (gateId === "vitest-app") return countVitestFailures(output);
if (gateId === "api-auth-surface") return countAuthSurfaceFailures(stdout, output);
return 0;
}
function runGate(gate) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(gate.command, gate.args, {
cwd: repoRoot,
env: { ...process.env, CI: "1" },
shell: false,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
stderr += `\n${error.message}`;
});
child.on("close", (code) => {
const output = `${stdout}\n${stderr}`;
resolve({
id: gate.id,
command: shellCommand(gate),
exitCode: code ?? 1,
durationMs: Date.now() - startedAt,
failureCount: failureCount(gate.id, stdout, stderr),
fingerprint: outputFingerprint(output),
});
});
});
}
export function renderReport(results) {
const lines = [
"# API Auth Routing Baseline Health",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This report captures non-E2E baseline gates for API Auth Routing baseline. The runner exits 0 when every gate was executed and this report was written, even if individual gates fail.",
"",
];
for (const result of results) {
lines.push(
`## ${result.id}`,
"",
`- Command: \`${result.command}\``,
`- Exitcode: ${result.exitCode}`,
`- Failure Count: ${result.failureCount}`,
`- Duration: ${result.durationMs}ms`,
`- Output Fingerprint: ${result.fingerprint.hash}`,
"",
"```text",
result.fingerprint.excerpt,
"```",
"",
);
}
return `${lines.join("\n")}\n`;
}
async function main() {
const results = [];
for (const gate of gates) {
console.log(`Running ${gate.id}: ${shellCommand(gate)}`);
results.push(await runGate(gate));
}
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, renderReport(results), "utf8");
console.log(`Wrote ${path.relative(repoRoot, reportPath)}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(async (error) => {
console.error(error);
try {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `# API Auth Routing Baseline Health\n\nRunner failed before report completion.\n\n\`\`\`text\n${error?.stack ?? error}\n\`\`\`\n`, "utf8");
} finally {
process.exit(1);
}
});
}
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { failureCount, gates, renderReport } from "./verify-api-auth-routing-baseline.mjs";
describe("verify-api-auth-routing-baseline", () => {
it("defines the expected non-E2E gate commands", () => {
assert.deepEqual(
gates.map((gate) => [gate.id, gate.command, gate.args]),
[
["typecheck", "pnpm", ["run", "typecheck"]],
["lint", "pnpm", ["run", "lint"]],
["vitest-app", "pnpm", ["exec", "vitest", "run", "apps/web/app/"]],
["api-auth-surface", "node", ["scripts/verify-api-auth-surface.mjs", "--json"]],
],
);
const commandText = gates.map((gate) => [gate.command, ...gate.args].join(" ")).join("\n");
assert.doesNotMatch(commandText, /playwright|cypress|browser|test:e2e/i);
});
it("derives failure counts from non-zero gate output", () => {
assert.equal(failureCount("typecheck", "src/a.ts(1,1): error TS2322: wrong\nerror TS7006", ""), 2);
assert.equal(failureCount("lint", "✖ 3 problems (2 errors, 1 warning)", ""), 2);
assert.equal(failureCount("lint", "2 errors and 0 warnings potentially fixable", ""), 2);
assert.equal(failureCount("vitest-app", "Tests 4 failed | 10 passed", ""), 4);
assert.equal(failureCount("vitest-app", "Test Files 3 failed | 20 passed", ""), 3);
assert.equal(
failureCount(
"api-auth-surface",
JSON.stringify({
directValidateApiKeyCallsites: [
{ file: "apps/api/src/app.ts" },
{ file: "apps/web/app/utils/services/apiService.ts" },
],
}),
"",
),
1,
);
assert.equal(failureCount("api-auth-surface", "not json", ""), 1);
});
it("renders API Auth Routing report sections with exitcodes, failure counts and fingerprints", () => {
const report = renderReport([
{
id: "typecheck",
command: "pnpm run typecheck",
exitCode: 2,
durationMs: 123,
failureCount: 5,
fingerprint: { hash: "abc123", excerpt: "error TS2322" },
},
]);
assert.match(report, /# API Auth Routing Baseline Health/);
assert.match(report, /API Auth Routing\ baseline/);
assert.doesNotMatch(report, /API Auth Surface Baseline Health/);
assert.match(report, /## typecheck/);
assert.match(report, /Exitcode: 2/);
assert.match(report, /Failure Count: 5/);
assert.match(report, /Output Fingerprint: abc123/);
});
});
@@ -0,0 +1,173 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const reportPath = path.join(repoRoot, ".tc/baselines/api-auth-surface/baseline-health.md");
const authCentralResolverFile = "apps/web/app/utils/services/apiService.ts";
export const gates = [
{ id: "typecheck", command: "pnpm", args: ["run", "typecheck"] },
{ id: "lint", command: "pnpm", args: ["run", "lint"] },
{ id: "vitest-app", command: "pnpm", args: ["exec", "vitest", "run", "apps/web/app/"] },
{ id: "api-auth-surface", command: "node", args: ["scripts/verify-api-auth-surface.mjs", "--json"] },
];
function shellCommand({ command, args }) {
return [command, ...args].join(" ");
}
function outputFingerprint(output) {
const normalized = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.slice(-40)
.join("\n");
const hash = createHash("sha256").update(output).digest("hex").slice(0, 12);
return {
hash,
excerpt: normalized.slice(0, 900) || "(no output)",
};
}
function parseNumber(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}
function countTypeScriptFailures(output) {
return (output.match(/error TS\d+/g) ?? []).length;
}
function countEslintFailures(output) {
const problemsMatch = output.match(/✖\s+(\d+)\s+problems?\s+\((\d+)\s+errors?/i);
if (problemsMatch) return parseNumber(problemsMatch[2]);
const compactMatch = output.match(/(\d+)\s+errors?/i);
if (compactMatch) return parseNumber(compactMatch[1]);
return 0;
}
function countVitestFailures(output) {
const failedTestMatch = output.match(/Tests\s+\d+\s+failed/i);
if (failedTestMatch) {
const countMatch = failedTestMatch[0].match(/Tests\s+(\d+)\s+failed/i);
return countMatch ? parseNumber(countMatch[1]) : 1;
}
const failedFilesMatch = output.match(/Test Files\s+(\d+)\s+failed/i);
return failedFilesMatch ? parseNumber(failedFilesMatch[1]) : 0;
}
function countAuthSurfaceFailures(stdout, output) {
try {
const inventory = JSON.parse(stdout);
return (inventory.directValidateApiKeyCallsites ?? []).filter((entry) => entry.file !== authCentralResolverFile).length;
} catch {
return output.trim() ? 1 : 0;
}
}
export function failureCount(gateId, stdout, stderr) {
const output = `${stdout}\n${stderr}`;
if (gateId === "typecheck") return countTypeScriptFailures(output);
if (gateId === "lint") return countEslintFailures(output);
if (gateId === "vitest-app") return countVitestFailures(output);
if (gateId === "api-auth-surface") return countAuthSurfaceFailures(stdout, output);
return 0;
}
function runGate(gate) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(gate.command, gate.args, {
cwd: repoRoot,
env: { ...process.env, CI: "1" },
shell: false,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
stderr += `\n${error.message}`;
});
child.on("close", (code) => {
const output = `${stdout}\n${stderr}`;
resolve({
id: gate.id,
command: shellCommand(gate),
exitCode: code ?? 1,
durationMs: Date.now() - startedAt,
failureCount: failureCount(gate.id, stdout, stderr),
fingerprint: outputFingerprint(output),
});
});
});
}
export function renderReport(results) {
const lines = [
"# API Auth Surface Baseline Health",
"",
`Generated: ${new Date().toISOString()}`,
"",
"This report captures non-E2E baseline gates for API Auth Surface baseline. The runner exits 0 when every gate was executed and this report was written, even if individual gates fail.",
"",
];
for (const result of results) {
lines.push(
`## ${result.id}`,
"",
`- Command: \`${result.command}\``,
`- Exitcode: ${result.exitCode}`,
`- Failure Count: ${result.failureCount}`,
`- Duration: ${result.durationMs}ms`,
`- Output Fingerprint: ${result.fingerprint.hash}`,
"",
"```text",
result.fingerprint.excerpt,
"```",
"",
);
}
return `${lines.join("\n")}\n`;
}
async function main() {
const results = [];
for (const gate of gates) {
console.log(`Running ${gate.id}: ${shellCommand(gate)}`);
results.push(await runGate(gate));
}
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, renderReport(results), "utf8");
console.log(`Wrote ${path.relative(repoRoot, reportPath)}`);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch(async (error) => {
console.error(error);
try {
await mkdir(path.dirname(reportPath), { recursive: true });
await writeFile(reportPath, `# API Auth Surface Baseline Health\n\nRunner failed before report completion.\n\n\`\`\`text\n${error?.stack ?? error}\n\`\`\`\n`, "utf8");
} finally {
process.exit(1);
}
});
}
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { describe, it } from "vitest";
import { failureCount, gates, renderReport } from "./verify-api-auth-surface-baseline.mjs";
describe("verify-api-auth-surface-baseline", () => {
it("defines the expected non-E2E gate commands", () => {
assert.deepEqual(
gates.map((gate) => [gate.id, gate.command, gate.args]),
[
["typecheck", "pnpm", ["run", "typecheck"]],
["lint", "pnpm", ["run", "lint"]],
["vitest-app", "pnpm", ["exec", "vitest", "run", "apps/web/app/"]],
["api-auth-surface", "node", ["scripts/verify-api-auth-surface.mjs", "--json"]],
],
);
});
it("derives failure counts from gate output", () => {
assert.equal(failureCount("typecheck", "src/a.ts(1,1): error TS2322: wrong\nerror TS7006", ""), 2);
assert.equal(failureCount("lint", "✖ 3 problems (2 errors, 1 warning)", ""), 2);
assert.equal(failureCount("vitest-app", "Tests 4 failed | 10 passed", ""), 4);
assert.equal(
failureCount(
"api-auth-surface",
JSON.stringify({
directValidateApiKeyCallsites: [
{ file: "apps/api/src/app.ts" },
{ file: "apps/web/app/utils/services/apiService.ts" },
],
}),
"",
),
1,
);
});
it("renders report sections with exitcodes, failure counts and fingerprints", () => {
const report = renderReport([
{
id: "typecheck",
command: "pnpm run typecheck",
exitCode: 2,
durationMs: 123,
failureCount: 5,
fingerprint: { hash: "abc123", excerpt: "error TS2322" },
},
]);
assert.match(report, /# API Auth Surface Baseline Health/);
assert.match(report, /## typecheck/);
assert.match(report, /Exitcode: 2/);
assert.match(report, /Failure Count: 5/);
assert.match(report, /Output Fingerprint: abc123/);
});
});
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const defaultRepoRoot = path.resolve(__dirname, "..");
const centralResolverFile = "apps/web/app/utils/services/apiService.ts";
const apiSurfaceRoots = [
{ path: "apps/api/src", required: true },
{ path: "apps/web/app/routes/internal/api", required: false },
];
const knownFlags = new Set(["--json"]);
const callsiteSymbols = [
["validateApiKey", "directValidateApiKeyCallsites"],
["validateTenantApiKey", "tenantValidateCallsites"],
["getEntityApiKeyFromRequest", "entityResolverCallsites"],
["getRelationshipApiKeyFromRequest", "relationshipHelperCallsites"],
["getWorkflowApiKeyFromRequest", "workflowHelperCallsites"],
];
function usage() {
return "Usage: node scripts/verify-api-auth-surface.mjs [--json]";
}
function toRelative(rootDir, filePath) {
return path.relative(rootDir, filePath).split(path.sep).join("/");
}
function sortEntries(entries) {
return entries.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.symbol.localeCompare(b.symbol));
}
function makeEntry(file, line, lineText, symbol) {
return {
file,
line,
symbol,
text: lineText.trim(),
};
}
async function collectRouteFiles(rootDir, dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await collectRouteFiles(rootDir, fullPath));
} else if (entry.isFile() && /\.tsx?$/.test(entry.name)) {
files.push(fullPath);
}
}
return files.sort((a, b) => toRelative(rootDir, a).localeCompare(toRelative(rootDir, b)));
}
export async function buildInventory(rootDir = defaultRepoRoot) {
const missingRoots = [];
const requiredMissingRoots = [];
const routeFiles = (
await Promise.all(
apiSurfaceRoots.map(async (root) => {
try {
return await collectRouteFiles(rootDir, path.join(rootDir, root.path));
} catch (error) {
if (error?.code === "ENOENT") {
missingRoots.push(root.path);
if (root.required) requiredMissingRoots.push(root.path);
return [];
}
throw error;
}
})
)
).flat().sort((a, b) => toRelative(rootDir, a).localeCompare(toRelative(rootDir, b)));
const inventory = {
scannedFiles: routeFiles.map((filePath) => toRelative(rootDir, filePath)),
scannedRoots: apiSurfaceRoots.map((root) => root.path).filter((root) => !missingRoots.includes(root)),
missingRoots: missingRoots.sort(),
requiredMissingRoots: requiredMissingRoots.sort(),
directValidateApiKeyCallsites: [],
tenantValidateCallsites: [],
entityResolverCallsites: [],
relationshipHelperCallsites: [],
workflowHelperCallsites: [],
apiServiceImports: [],
centralResolverFile,
};
for (const filePath of routeFiles) {
const file = toRelative(rootDir, filePath);
const source = await readFile(filePath, "utf8");
source.split(/\r?\n/).forEach((lineText, index) => {
const line = index + 1;
if (lineText.includes("~/utils/services/apiService")) {
inventory.apiServiceImports.push(makeEntry(file, line, lineText, "apiService import"));
}
for (const [symbol, field] of callsiteSymbols) {
const pattern = new RegExp(`\\b${symbol}\\s*\\(`);
if (pattern.test(lineText)) {
inventory[field].push(makeEntry(file, line, lineText, symbol));
}
}
});
}
for (const [, field] of callsiteSymbols) {
sortEntries(inventory[field]);
}
sortEntries(inventory.apiServiceImports);
return inventory;
}
export function hasInventoryIntegrityFailure(inventory) {
return inventory.requiredMissingRoots.length > 0 || inventory.scannedFiles.length === 0;
}
function renderSection(title, entries) {
const rows = entries.length
? entries.map((entry) => ` ${entry.file}:${entry.line} ${entry.symbol} ${entry.text}`)
: [" (none)"];
return [title, ...rows].join("\n");
}
export function renderText(inventory) {
return [
"API Auth Surface API auth surface inventory",
`Scanned files: ${inventory.scannedFiles.length}`,
`Scanned roots: ${inventory.scannedRoots.join(", ") || "(none)"}`,
`Missing roots: ${inventory.missingRoots.join(", ") || "(none)"}`,
`Central resolver file: ${inventory.centralResolverFile}`,
renderSection("\nvalidateApiKey callsites", inventory.directValidateApiKeyCallsites),
renderSection("\nvalidateTenantApiKey callsites", inventory.tenantValidateCallsites),
renderSection("\ngetEntityApiKeyFromRequest callsites", inventory.entityResolverCallsites),
renderSection("\ngetRelationshipApiKeyFromRequest callsites", inventory.relationshipHelperCallsites),
renderSection("\ngetWorkflowApiKeyFromRequest callsites", inventory.workflowHelperCallsites),
renderSection("\napiService imports", inventory.apiServiceImports),
].join("\n");
}
async function main() {
const args = process.argv.slice(2);
const unknownFlag = args.find((arg) => arg.startsWith("-") && !knownFlags.has(arg));
if (unknownFlag) {
console.error(`Unknown flag: ${unknownFlag}`);
console.error(usage());
process.exit(2);
}
try {
const inventory = await buildInventory(defaultRepoRoot);
if (hasInventoryIntegrityFailure(inventory)) {
console.error(renderText(inventory));
console.error("API auth surface inventory integrity check failed.");
process.exit(2);
}
if (args.includes("--json")) {
console.log(JSON.stringify(inventory, null, 2));
} else {
console.log(renderText(inventory));
}
} catch (error) {
if (error?.code === "ENOENT") {
console.error("Missing API routes directory: apps/web/app/routes/internal/api");
process.exit(2);
}
throw error;
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}
@@ -0,0 +1,134 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { afterEach, describe, it } from "vitest";
import { buildInventory, hasInventoryIntegrityFailure, renderText } from "./verify-api-auth-surface.mjs";
const fixtures = [];
function writeFile(rootDir, relativePath, content) {
const absolutePath = path.join(rootDir, relativePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, content);
}
function createFixture() {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "api-auth-surface-"));
fixtures.push(rootDir);
return rootDir;
}
afterEach(() => {
while (fixtures.length > 0) {
fs.rmSync(fixtures.pop(), { recursive: true, force: true });
}
});
describe("verify-api-auth-surface", () => {
it("builds stable JSON inventory for apiService imports and callsites", async () => {
const rootDir = createFixture();
writeFile(
rootDir,
"apps/api/src/entity-route.ts",
`import { validateApiKey } from "~/utils/services/apiService";
export async function loader({ request, params }) {
return validateApiKey(request, params);
}
`,
);
writeFile(
rootDir,
"apps/api/src/usage-route.ts",
`import { validateTenantApiKey, getEntityApiKeyFromRequest } from "~/utils/services/apiService";
export async function loader({ request, params }) {
await validateTenantApiKey(request, params);
return getEntityApiKeyFromRequest(request, params);
}
`,
);
writeFile(
rootDir,
"apps/api/src/relationships-route.ts",
`import { getRelationshipApiKeyFromRequest } from "~/utils/services/apiService";
export async function action({ request, params }) {
return getRelationshipApiKeyFromRequest(request, params);
}
`,
);
writeFile(
rootDir,
"apps/api/src/workflows-route.ts",
`import { getWorkflowApiKeyFromRequest } from "~/utils/services/apiService";
export async function action({ request, params }) {
return getWorkflowApiKeyFromRequest(request, params);
}
`,
);
const inventory = await buildInventory(rootDir);
assert.deepEqual(inventory.scannedFiles, [
"apps/api/src/entity-route.ts",
"apps/api/src/relationships-route.ts",
"apps/api/src/usage-route.ts",
"apps/api/src/workflows-route.ts",
]);
assert.deepEqual(inventory.scannedRoots, ["apps/api/src"]);
assert.deepEqual(inventory.missingRoots, ["apps/web/app/routes/internal/api"]);
assert.deepEqual(inventory.requiredMissingRoots, []);
assert.equal(hasInventoryIntegrityFailure(inventory), false);
assert.deepEqual(
inventory.directValidateApiKeyCallsites.map((entry) => `${entry.file}:${entry.line}:${entry.symbol}`),
["apps/api/src/entity-route.ts:3:validateApiKey"],
);
assert.deepEqual(
inventory.tenantValidateCallsites.map((entry) => `${entry.file}:${entry.line}:${entry.symbol}`),
["apps/api/src/usage-route.ts:3:validateTenantApiKey"],
);
assert.deepEqual(
inventory.entityResolverCallsites.map((entry) => `${entry.file}:${entry.line}:${entry.symbol}`),
["apps/api/src/usage-route.ts:4:getEntityApiKeyFromRequest"],
);
assert.deepEqual(
inventory.relationshipHelperCallsites.map((entry) => `${entry.file}:${entry.line}:${entry.symbol}`),
["apps/api/src/relationships-route.ts:3:getRelationshipApiKeyFromRequest"],
);
assert.deepEqual(
inventory.workflowHelperCallsites.map((entry) => `${entry.file}:${entry.line}:${entry.symbol}`),
["apps/api/src/workflows-route.ts:3:getWorkflowApiKeyFromRequest"],
);
assert.equal(inventory.apiServiceImports.length, 4);
assert.equal(inventory.centralResolverFile, "apps/web/app/utils/services/apiService.ts");
assert.doesNotThrow(() => JSON.parse(JSON.stringify(inventory)));
const text = renderText(inventory);
assert.match(text, /API Auth Surface API auth surface inventory/);
assert.match(text, /getRelationshipApiKeyFromRequest callsites/);
assert.match(text, /getWorkflowApiKeyFromRequest callsites/);
});
it("CLI exits 2 for unknown flags", () => {
const scriptPath = path.resolve("scripts/verify-api-auth-surface.mjs");
assert.throws(
() => execFileSync(process.execPath, [scriptPath, "--bogus"], { encoding: "utf8", stdio: "pipe" }),
(error) => {
assert.equal(error.status, 2);
assert.match(error.stderr, /Unknown flag: --bogus/);
return true;
},
);
});
it("marks missing required API source roots as inventory failures", async () => {
const rootDir = createFixture();
const inventory = await buildInventory(rootDir);
assert.deepEqual(inventory.scannedFiles, []);
assert.deepEqual(inventory.requiredMissingRoots, ["apps/api/src"]);
assert.equal(hasInventoryIntegrityFailure(inventory), true);
});
});
@@ -0,0 +1,143 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
export const schemaPath = path.join(repoRoot, "prisma/schema/accounts.prisma");
export const migrationPath = path.join(
repoRoot,
"prisma/migrations/20260508140000_impersonation_audit/migration.sql",
);
export function extractModel(schemaText, modelName) {
const modelMatch = schemaText.match(new RegExp(`model\\s+${modelName}\\s+\\{([\\s\\S]*?)\\n\\}`));
return modelMatch?.[1] ?? null;
}
function assertMatch(failures, id, text, pattern, message) {
if (!pattern.test(text)) {
failures.push({ id, message });
}
}
function normalizeSql(sqlText) {
return sqlText.replace(/\s+/g, " ").trim();
}
export function verifyAuditCascadeGuard({ schemaText, migrationSql }) {
const failures = [];
const model = extractModel(schemaText, "ImpersonationAudit");
if (!model) {
failures.push({ id: "schema.model.missing", message: "Prisma schema is missing model ImpersonationAudit." });
} else {
assertMatch(
failures,
"schema.actorUserId.nullable",
model,
/^\s*actorUserId\s+String\?/m,
"ImpersonationAudit.actorUserId must be nullable (String?).",
);
assertMatch(
failures,
"schema.targetUserId.nullable",
model,
/^\s*targetUserId\s+String\?/m,
"ImpersonationAudit.targetUserId must be nullable (String?).",
);
assertMatch(
failures,
"schema.actor.optional-set-null",
model,
/^\s*actor\s+User\?\s+@relation\([^\n]*fields:\s*\[actorUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.actor relation must be optional and use onDelete: SetNull.",
);
assertMatch(
failures,
"schema.target.optional-set-null",
model,
/^\s*target\s+User\?\s+@relation\([^\n]*fields:\s*\[targetUserId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.target relation must be optional and use onDelete: SetNull.",
);
assertMatch(
failures,
"schema.tenant.optional-set-null",
model,
/^\s*tenant\s+Tenant\?\s+@relation\([^\n]*fields:\s*\[tenantId\][^\n]*onDelete:\s*SetNull[^\n]*\)/m,
"ImpersonationAudit.tenant relation must be optional and use onDelete: SetNull.",
);
const cascadeRelations = model
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /@relation\(/.test(line) && /onDelete:\s*Cascade/.test(line));
for (const relation of cascadeRelations) {
failures.push({
id: "schema.relation.no-cascade",
message: `ImpersonationAudit relation must not use onDelete: Cascade: ${relation}`,
});
}
}
const normalizedSql = normalizeSql(migrationSql);
assertMatch(
failures,
"migration.actorUserId.nullable",
normalizedSql,
/"actorUserId"\s+TEXT\s*(?:,|$)/,
'Migration column "actorUserId" must be nullable TEXT, not TEXT NOT NULL.',
);
assertMatch(
failures,
"migration.targetUserId.nullable",
normalizedSql,
/"targetUserId"\s+TEXT\s*(?:,|$)/,
'Migration column "targetUserId" must be nullable TEXT, not TEXT NOT NULL.',
);
const expectedForeignKeys = [
["migration.actorUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_actorUserId_fkey"\s+FOREIGN KEY\s+\("actorUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i],
["migration.targetUserId.set-null", /CONSTRAINT\s+"ImpersonationAudit_targetUserId_fkey"\s+FOREIGN KEY\s+\("targetUserId"\)\s+REFERENCES\s+"User"\("id"\)\s+ON DELETE SET NULL/i],
["migration.tenantId.set-null", /CONSTRAINT\s+"ImpersonationAudit_tenantId_fkey"\s+FOREIGN KEY\s+\("tenantId"\)\s+REFERENCES\s+"Tenant"\("id"\)\s+ON DELETE SET NULL/i],
];
for (const [id, pattern] of expectedForeignKeys) {
assertMatch(failures, id, normalizedSql, pattern, `${id} foreign key must use ON DELETE SET NULL.`);
}
return { ok: failures.length === 0, failures };
}
export function formatAuditCascadeGuardResult(result) {
if (result.ok) {
return "PASS impersonation audit cascade guard: schema and migration retain audit rows with nullable SetNull relations.";
}
return [
"FAIL impersonation audit cascade guard",
...result.failures.map((failure) => `- ${failure.id}: ${failure.message}`),
].join("\n");
}
async function main() {
const [schemaText, migrationSql] = await Promise.all([readFile(schemaPath, "utf8"), readFile(migrationPath, "utf8")]);
const result = verifyAuditCascadeGuard({ schemaText, migrationSql });
const output = formatAuditCascadeGuardResult(result);
if (result.ok) {
console.log(output);
return;
}
console.error(output);
process.exit(1);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error);
process.exit(1);
});
}
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff. Show more