Track HiveGrid Cloud planning reference
This commit is contained in:
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
@@ -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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Executable
+25
@@ -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
|
||||
Executable
+127
@@ -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
@@ -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
|
||||
Executable
+93
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
Executable
+46
@@ -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
|
||||
@@ -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();
|
||||
@@ -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
@@ -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"/);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
+2
@@ -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();
|
||||
+2
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(""", "\"")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll(""", "\"")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("&", "&");
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Executable
+29
@@ -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
Reference in new issue
Block a user