2026-06-15 12:53:06 +08:00
|
|
|
import { readdirSync } from "node:fs";
|
2026-03-25 09:27:12 +08:00
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { defineConfig } from "vitest/config";
|
2026-07-27 00:06:04 +08:00
|
|
|
import { BaseSequencer } from "vitest/node";
|
|
|
|
|
import { partitionByCost } from "./tests/helpers/shard-partition.js";
|
2026-03-23 11:46:45 +08:00
|
|
|
|
|
|
|
|
// Resolve api-workspace packages that pnpm only exposes under apps/api/node_modules.
|
|
|
|
|
const apiNodeModules = path.resolve(__dirname, "apps/api/node_modules");
|
|
|
|
|
|
2026-04-20 22:00:27 +08:00
|
|
|
// Resolve web-workspace packages that pnpm only exposes under apps/web/node_modules.
|
|
|
|
|
const webNodeModules = path.resolve(__dirname, "apps/web/node_modules");
|
|
|
|
|
|
2026-04-24 13:15:15 +08:00
|
|
|
// Resolve landing-workspace packages.
|
|
|
|
|
const landingNodeModules = path.resolve(__dirname, "apps/landing/node_modules");
|
|
|
|
|
|
2026-06-15 12:53:06 +08:00
|
|
|
// @opentelemetry/core is a transitive dep (via sdk-node) not hoisted by pnpm.
|
|
|
|
|
// Find it in the pnpm store so tests can import W3CTraceContextPropagator.
|
|
|
|
|
function findPnpmPackage(scope: string, name: string): string {
|
|
|
|
|
const pnpmDir = path.resolve(__dirname, "node_modules/.pnpm");
|
|
|
|
|
const prefix = `${scope}+${name}@`;
|
|
|
|
|
const entries = readdirSync(pnpmDir)
|
|
|
|
|
.filter((e) => e.startsWith(prefix))
|
|
|
|
|
.sort();
|
|
|
|
|
if (entries.length === 0) {
|
|
|
|
|
throw new Error(`${scope}/${name} not found in pnpm store`);
|
|
|
|
|
}
|
|
|
|
|
return path.join(pnpmDir, entries[entries.length - 1], "node_modules", scope, name);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 00:06:04 +08:00
|
|
|
/**
|
|
|
|
|
* Shards by measured cost instead of by SHA1 of the file path.
|
|
|
|
|
*
|
|
|
|
|
* The stock BaseSequencer slices an equal number of FILES per shard, which took
|
|
|
|
|
* no account of the fact that four generated matrix specs carry most of the
|
|
|
|
|
* suite's runtime. See tests/helpers/shard-partition.ts for the measurements.
|
|
|
|
|
*
|
|
|
|
|
* `sort` is inherited untouched, and vitest only calls `shard` when --shard is
|
|
|
|
|
* passed, so unsharded runs (local, nightly full matrix) behave exactly as before.
|
|
|
|
|
*/
|
|
|
|
|
class CostAwareSequencer extends BaseSequencer {
|
|
|
|
|
async shard(specs: Parameters<BaseSequencer["shard"]>[0]) {
|
|
|
|
|
const shardConfig = this.ctx.config.shard;
|
|
|
|
|
if (!shardConfig) return specs;
|
|
|
|
|
|
|
|
|
|
const { index, count } = shardConfig;
|
|
|
|
|
const byModuleId = new Map(specs.map((spec) => [spec.moduleId, spec]));
|
|
|
|
|
const bin = partitionByCost([...byModuleId.keys()], count)[index - 1] ?? [];
|
|
|
|
|
|
|
|
|
|
return bin.map((moduleId) => byModuleId.get(moduleId)).filter((spec) => spec !== undefined);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-23 11:46:45 +08:00
|
|
|
export default defineConfig({
|
2026-04-24 13:15:15 +08:00
|
|
|
esbuild: {
|
|
|
|
|
jsx: "automatic",
|
|
|
|
|
},
|
2026-03-23 11:46:45 +08:00
|
|
|
test: {
|
|
|
|
|
globals: true,
|
2026-06-16 16:35:20 +08:00
|
|
|
// Env-overridable so the resource-constrained docker test image (where Sharp
|
|
|
|
|
// and FFmpeg run ~2-3x slower under the macOS Docker VM) can grant slow
|
|
|
|
|
// sync-wait jobs more headroom. Host/CI keep the 30s default.
|
|
|
|
|
testTimeout: Number(process.env.VITEST_TEST_TIMEOUT) || 30_000,
|
|
|
|
|
hookTimeout: Number(process.env.VITEST_HOOK_TIMEOUT) || 30_000,
|
2026-04-14 22:27:32 +08:00
|
|
|
pool: "forks",
|
|
|
|
|
poolOptions: {
|
|
|
|
|
forks: {
|
2026-06-10 22:01:13 +08:00
|
|
|
// Parallel forks; each fork gets an isolated DB + workspace via
|
2026-06-24 18:23:23 +08:00
|
|
|
// tests/setup/per-fork-env.ts. CI runners have 4 vCPUs. Env-overridable
|
|
|
|
|
// (VITEST_MAX_FORKS) so the heavy nightly jobs (FULL_MATRIX, coverage)
|
|
|
|
|
// can drop to fewer forks and give each slow media test more CPU, which
|
|
|
|
|
// keeps format-matrix conversions from starving and timing out.
|
|
|
|
|
maxForks:
|
|
|
|
|
Number(process.env.VITEST_MAX_FORKS) ||
|
|
|
|
|
(process.env.CI ? 4 : Math.max(2, Math.floor(os.availableParallelism() / 2))),
|
2026-04-14 22:27:32 +08:00
|
|
|
},
|
|
|
|
|
},
|
2026-07-27 00:06:04 +08:00
|
|
|
sequence: {
|
|
|
|
|
sequencer: CostAwareSequencer,
|
|
|
|
|
},
|
2026-06-13 10:15:23 +08:00
|
|
|
globalSetup: ["tests/global-setup.ts"],
|
2026-06-10 22:01:13 +08:00
|
|
|
setupFiles: ["tests/setup/per-fork-env.ts"],
|
2026-03-26 01:31:49 +08:00
|
|
|
exclude: [
|
|
|
|
|
"tests/e2e/**",
|
2026-07-24 03:54:50 +08:00
|
|
|
"tests/e2e-demo/**",
|
2026-04-29 01:39:25 +08:00
|
|
|
"tests/e2e-docs/**",
|
2026-05-07 23:47:08 +08:00
|
|
|
"tests/e2e-editor/**",
|
2026-04-29 01:39:25 +08:00
|
|
|
"tests/e2e-landing/**",
|
2026-04-17 14:24:02 +08:00
|
|
|
"tests/e2e-docker/**",
|
2026-04-29 23:47:19 +08:00
|
|
|
"tests/e2e-analytics/**",
|
2026-07-27 15:37:30 +08:00
|
|
|
"tests/e2e-noauth/**",
|
2026-06-16 15:16:40 +08:00
|
|
|
// tests/qa/* are Playwright QA-sweep specs (and .mts harnesses), not
|
|
|
|
|
// vitest tests; they import @playwright/test and break a bare `vitest run`.
|
|
|
|
|
"tests/qa/**",
|
2026-03-26 01:31:49 +08:00
|
|
|
"**/node_modules/**",
|
|
|
|
|
"**/dist/**",
|
|
|
|
|
"**/.{idea,git,cache,output,temp}/**",
|
2026-04-21 09:26:22 +08:00
|
|
|
".worktrees/**",
|
|
|
|
|
".claude/**",
|
2026-07-27 15:37:30 +08:00
|
|
|
// Stryker copies the whole project into .stryker-tmp*/sandbox-XXXX while a
|
|
|
|
|
// mutation run is in flight, and leaves it behind entirely if that run
|
|
|
|
|
// crashes. Without this, a concurrent `vitest run` discovers the copies and
|
|
|
|
|
// reports "Cannot find module" failures from a directory that is gitignored
|
|
|
|
|
// build scratch. Those phantom failures are indistinguishable from real
|
|
|
|
|
// ones at a glance, which is exactly the wrong thing to hand a release
|
|
|
|
|
// verdict. The configs also set cleanTempDir: "always" so the scratch does
|
|
|
|
|
// not survive a crash in the first place.
|
|
|
|
|
".stryker-tmp*/**",
|
|
|
|
|
"**/.stryker-tmp*/**",
|
2026-03-26 01:31:49 +08:00
|
|
|
],
|
2026-03-23 11:46:45 +08:00
|
|
|
env: {
|
|
|
|
|
AUTH_ENABLED: "true",
|
|
|
|
|
DEFAULT_USERNAME: "admin",
|
2026-03-24 21:38:06 +08:00
|
|
|
DEFAULT_PASSWORD: "Adminpass1",
|
2026-06-13 10:15:23 +08:00
|
|
|
// DATABASE_URL and WORKSPACE_PATH are set per-fork in tests/setup/per-fork-env.ts
|
2026-03-23 11:46:45 +08:00
|
|
|
MAX_UPLOAD_SIZE_MB: "10",
|
|
|
|
|
MAX_BATCH_SIZE: "10",
|
|
|
|
|
RATE_LIMIT_PER_MIN: "10000",
|
2026-05-15 09:25:27 +08:00
|
|
|
MAX_USERS: "0",
|
2026-03-23 11:46:45 +08:00
|
|
|
MAX_MEGAPIXELS: "100",
|
|
|
|
|
CONCURRENT_JOBS: "3",
|
|
|
|
|
FILE_MAX_AGE_HOURS: "1",
|
|
|
|
|
CLEANUP_INTERVAL_MINUTES: "60",
|
2026-06-08 10:12:01 +08:00
|
|
|
MAX_PIPELINE_STEPS: "0",
|
2026-03-23 11:46:45 +08:00
|
|
|
},
|
|
|
|
|
coverage: {
|
|
|
|
|
provider: "v8",
|
|
|
|
|
reporter: ["text", "html", "lcov"],
|
2026-07-27 15:37:30 +08:00
|
|
|
// Vitest defaults this to false, which silently throws away the entire
|
|
|
|
|
// report (text table, lcov, html) and the threshold check below the
|
|
|
|
|
// moment any test fails. The run then reads as "tests failed" when the
|
|
|
|
|
// ratchet in fact never ran, so a coverage regression can hide behind an
|
|
|
|
|
// unrelated failure. Always emit; the exit code still comes from the
|
|
|
|
|
// tests.
|
|
|
|
|
reportOnFailure: true,
|
2026-06-10 22:01:13 +08:00
|
|
|
// Ratchet: measured 2026-06-10 at lines 77.7 / branches 83.7 /
|
|
|
|
|
// functions 86.5 over unit+integration. Raise when coverage rises;
|
|
|
|
|
// never lower without a written reason.
|
2026-06-24 20:08:40 +08:00
|
|
|
// 2026-06-24: re-baselined branches 81 -> 80 and functions 84 -> 83.
|
2026-06-28 18:57:53 +08:00
|
|
|
// The 2.0 surface growth (240 tools) plus the analytics-system removal
|
2026-06-24 20:08:40 +08:00
|
|
|
// (#336 deleted tested code) settled the all-tests-passing measurement at
|
|
|
|
|
// branches 80.97 / functions 83.36, just under the old ratchet. Per-tool
|
|
|
|
|
// integration tests still cover the critical paths.
|
2026-06-10 22:01:13 +08:00
|
|
|
thresholds: {
|
|
|
|
|
lines: 75,
|
2026-06-24 20:08:40 +08:00
|
|
|
branches: 80,
|
|
|
|
|
functions: 83,
|
2026-06-10 22:01:13 +08:00
|
|
|
statements: 75,
|
|
|
|
|
},
|
2026-03-23 11:46:45 +08:00
|
|
|
include: [
|
|
|
|
|
"packages/image-engine/src/**",
|
|
|
|
|
"apps/api/src/**",
|
|
|
|
|
"apps/web/src/stores/**",
|
|
|
|
|
"apps/web/src/lib/**",
|
|
|
|
|
],
|
|
|
|
|
exclude: [
|
|
|
|
|
"**/*.d.ts",
|
|
|
|
|
"**/node_modules/**",
|
|
|
|
|
"**/dist/**",
|
|
|
|
|
"apps/api/src/db/migrate.ts",
|
|
|
|
|
"apps/api/src/index.ts",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
resolve: {
|
|
|
|
|
alias: {
|
2026-04-24 13:15:15 +08:00
|
|
|
"@landing": path.resolve(__dirname, "apps/landing/src"),
|
|
|
|
|
// Landing page components that don't exist in web but are imported via @/
|
|
|
|
|
"@/components/fade-in": path.resolve(__dirname, "apps/landing/src/components/fade-in"),
|
|
|
|
|
"@/components/footer": path.resolve(__dirname, "apps/landing/src/components/footer"),
|
2026-05-16 19:12:43 +08:00
|
|
|
"@/components/json-ld": path.resolve(__dirname, "apps/landing/src/components/json-ld"),
|
2026-04-24 13:15:15 +08:00
|
|
|
"@/components/navbar": path.resolve(__dirname, "apps/landing/src/components/navbar"),
|
2026-03-23 11:46:45 +08:00
|
|
|
"@": path.resolve(__dirname, "apps/web/src"),
|
2026-04-24 13:15:15 +08:00
|
|
|
"framer-motion": path.join(landingNodeModules, "framer-motion"),
|
2026-07-17 00:36:48 +08:00
|
|
|
"pdfjs-dist": path.join(webNodeModules, "pdfjs-dist"),
|
2026-06-13 10:18:39 +08:00
|
|
|
"@snapotter/ai": path.resolve(__dirname, "packages/ai/src/index.ts"),
|
2026-06-13 10:15:23 +08:00
|
|
|
"@snapotter/enterprise": path.resolve(__dirname, "packages/enterprise/src/index.ts"),
|
2026-04-24 18:02:21 +08:00
|
|
|
"@snapotter/image-engine": path.resolve(__dirname, "packages/image-engine/src/index.ts"),
|
2026-06-13 10:18:39 +08:00
|
|
|
"@snapotter/media-engine": path.resolve(__dirname, "packages/media-engine/src/index.ts"),
|
|
|
|
|
"@snapotter/doc-engine": path.resolve(__dirname, "packages/doc-engine/src/index.ts"),
|
2026-05-15 17:02:49 +08:00
|
|
|
"@snapotter/shared/i18n": path.resolve(__dirname, "packages/shared/src/i18n"),
|
2026-06-28 18:57:53 +08:00
|
|
|
"@snapotter/shared/search": path.resolve(__dirname, "packages/shared/src/search"),
|
2026-04-24 18:02:21 +08:00
|
|
|
"@snapotter/shared": path.resolve(__dirname, "packages/shared/src/index.ts"),
|
2026-03-25 09:27:12 +08:00
|
|
|
fastify: path.join(apiNodeModules, "fastify"),
|
2026-05-13 19:14:34 +08:00
|
|
|
"@fastify/cookie": path.join(apiNodeModules, "@fastify/cookie"),
|
2026-03-23 11:46:45 +08:00
|
|
|
"@fastify/cors": path.join(apiNodeModules, "@fastify/cors"),
|
|
|
|
|
"@fastify/multipart": path.join(apiNodeModules, "@fastify/multipart"),
|
|
|
|
|
"@fastify/rate-limit": path.join(apiNodeModules, "@fastify/rate-limit"),
|
|
|
|
|
"@fastify/static": path.join(apiNodeModules, "@fastify/static"),
|
|
|
|
|
"@fastify/swagger": path.join(apiNodeModules, "@fastify/swagger"),
|
|
|
|
|
"@fastify/swagger-ui": path.join(apiNodeModules, "@fastify/swagger-ui"),
|
|
|
|
|
"better-sqlite3": path.join(apiNodeModules, "better-sqlite3"),
|
2026-06-13 10:15:23 +08:00
|
|
|
pg: path.join(apiNodeModules, "pg"),
|
2026-03-23 11:46:45 +08:00
|
|
|
"drizzle-orm": path.join(apiNodeModules, "drizzle-orm"),
|
2026-03-25 09:27:12 +08:00
|
|
|
archiver: path.join(apiNodeModules, "archiver"),
|
2026-03-23 11:46:45 +08:00
|
|
|
"p-queue": path.join(apiNodeModules, "p-queue"),
|
2026-03-25 09:27:12 +08:00
|
|
|
dotenv: path.join(apiNodeModules, "dotenv"),
|
|
|
|
|
potrace: path.join(apiNodeModules, "potrace"),
|
|
|
|
|
qrcode: path.join(apiNodeModules, "qrcode"),
|
|
|
|
|
jsqr: path.join(apiNodeModules, "jsqr"),
|
|
|
|
|
pdfkit: path.join(apiNodeModules, "pdfkit"),
|
2026-06-15 12:53:06 +08:00
|
|
|
pino: path.join(apiNodeModules, "pino"),
|
2026-03-25 09:35:28 +08:00
|
|
|
sharp: path.join(apiNodeModules, "sharp"),
|
2026-06-13 10:17:13 +08:00
|
|
|
ioredis: path.join(apiNodeModules, "ioredis"),
|
|
|
|
|
bullmq: path.join(apiNodeModules, "bullmq"),
|
2026-06-13 22:49:00 +08:00
|
|
|
otpauth: path.join(apiNodeModules, "otpauth"),
|
2026-05-13 19:14:34 +08:00
|
|
|
"openid-client": path.join(apiNodeModules, "openid-client"),
|
2026-07-24 17:36:57 +08:00
|
|
|
"@node-saml/node-saml": path.join(apiNodeModules, "@node-saml/node-saml"),
|
2026-05-08 16:01:46 +08:00
|
|
|
"opentype.js": path.join(apiNodeModules, "opentype.js"),
|
2026-06-04 22:22:40 +08:00
|
|
|
"posthog-node": path.join(apiNodeModules, "posthog-node"),
|
|
|
|
|
"@sentry/node": path.join(apiNodeModules, "@sentry/node"),
|
2026-06-13 10:15:23 +08:00
|
|
|
"@aws-sdk/client-s3": path.join(
|
|
|
|
|
path.resolve(__dirname, "packages/enterprise/node_modules"),
|
|
|
|
|
"@aws-sdk/client-s3",
|
|
|
|
|
),
|
2026-04-20 22:00:27 +08:00
|
|
|
react: path.join(webNodeModules, "react"),
|
|
|
|
|
"react-dom": path.join(webNodeModules, "react-dom"),
|
2026-07-27 15:37:30 +08:00
|
|
|
"react-router": path.join(webNodeModules, "react-router"),
|
2026-04-23 17:12:02 +08:00
|
|
|
zustand: path.join(webNodeModules, "zustand"),
|
2026-06-28 18:57:53 +08:00
|
|
|
"fuse.js": path.join(webNodeModules, "fuse.js"),
|
2026-04-29 14:16:38 +08:00
|
|
|
"posthog-js": path.join(webNodeModules, "posthog-js"),
|
|
|
|
|
"@sentry/react": path.join(webNodeModules, "@sentry/react"),
|
2026-06-15 12:53:06 +08:00
|
|
|
"@opentelemetry/api": path.join(apiNodeModules, "@opentelemetry/api"),
|
|
|
|
|
"@opentelemetry/core": findPnpmPackage("@opentelemetry", "core"),
|
|
|
|
|
"@opentelemetry/sdk-node": path.join(apiNodeModules, "@opentelemetry/sdk-node"),
|
|
|
|
|
"@opentelemetry/sdk-trace-base": path.join(apiNodeModules, "@opentelemetry/sdk-trace-base"),
|
|
|
|
|
"@opentelemetry/exporter-trace-otlp-http": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/exporter-trace-otlp-http",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/resources": path.join(apiNodeModules, "@opentelemetry/resources"),
|
|
|
|
|
"@opentelemetry/semantic-conventions": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/semantic-conventions",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/instrumentation-http": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/instrumentation-http",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/instrumentation-fastify": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/instrumentation-fastify",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/instrumentation-pg": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/instrumentation-pg",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/instrumentation-ioredis": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/instrumentation-ioredis",
|
|
|
|
|
),
|
|
|
|
|
"@opentelemetry/instrumentation-aws-sdk": path.join(
|
|
|
|
|
apiNodeModules,
|
|
|
|
|
"@opentelemetry/instrumentation-aws-sdk",
|
|
|
|
|
),
|
2026-03-23 11:46:45 +08:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|