diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97cfbc021b..2ecb0c953f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,8 +36,8 @@ jobs: - run: bun run fmt:check # A single fast pass over every workspace's tests. DB-gated suites - # (scripts/e2e/* included) skip themselves when DATABASE_URL is unset — - # see scripts/e2e/db-gate.ts — so this job needs no Postgres service. + # (e2e/* included) skip themselves when DATABASE_URL is unset — + # see e2e/db-gate.ts — so this job needs no Postgres service. unit: needs: setup runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1f1ea395ab..903d12c703 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,7 @@ tmp/ .corbits/ # Browser walkthrough harness screenshots + run summary (CL-6072) -scripts/e2e/browser/shots/ +e2e/browser/shots/ # `bun pm pack` / `bun run release` tarballs — built on demand, never # committed (CL-8167). diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 6c397bdecf..44a4aabcb8 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -9,7 +9,7 @@ import { afterAll, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { dbGate } from "../../../e2e/db-gate"; const databaseUrl = process.env["DATABASE_URL"] ?? ""; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/apps/hub/test/mailbox-persist.test.ts b/apps/hub/test/mailbox-persist.test.ts index 86e4ce8475..686d0fad28 100644 --- a/apps/hub/test/mailbox-persist.test.ts +++ b/apps/hub/test/mailbox-persist.test.ts @@ -21,7 +21,7 @@ import { } from "@corbits/mailbox"; import { createHubSessionLookups, type AgentRepoStore } from "@intx/hub-sessions"; import { createHubMailboxAuthorizeSender } from "../src/mailbox-persist"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { dbGate } from "../../../e2e/db-gate"; const databaseUrl = process.env["DATABASE_URL"] ?? ""; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/scripts/e2e/browser/walkthrough.ts b/e2e/browser/walkthrough.ts similarity index 99% rename from scripts/e2e/browser/walkthrough.ts rename to e2e/browser/walkthrough.ts index 34b5a80b3a..80f4079ec8 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/e2e/browser/walkthrough.ts @@ -7,17 +7,17 @@ // scratch database, the real hub, the real sidecar, a stubbed provider // credential) and drives the actual built web app in headless Chrome via // puppeteer-core — clicking, typing, and screenshotting exactly as a human -// would. Scripted API-only e2e suites (scripts/e2e/*.test.ts) had been +// would. Scripted API-only e2e suites (e2e/*.test.ts) had been // passing while the real UI broke; this is the acceptance mechanism for // "works in the owner's browser." // -// Run: bun run scripts/e2e/browser/walkthrough.ts +// Run: bun run e2e/browser/walkthrough.ts // Requires: DATABASE_URL (see .env.example), and a system Chrome/Chromium // (this script looks in the usual per-OS install locations — no download). // -// Every step screenshots to scripts/e2e/browser/shots/NN-name.png and +// Every step screenshots to e2e/browser/shots/NN-name.png and // records its own pass/repro-confirmed/fail outcome; the full run also -// writes scripts/e2e/browser/shots/summary.json. Re-runnable: resets its +// writes e2e/browser/shots/summary.json. Re-runnable: resets its // own scratch database on every run. Finishes inside a 3-minute budget — // every wait below is bounded, nothing blocks forever. @@ -27,7 +27,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import puppeteer, { type Browser, type Page } from "puppeteer-core"; -import { resetSchema, setupDatabase } from "../../db-setup.ts"; +import { resetSchema, setupDatabase } from "../../scripts/db-setup.ts"; import { e2eDatabaseUrl, freePort, @@ -37,7 +37,7 @@ import { type HubHandle, type SpawnedApp, } from "../harness.ts"; -import { OLLAMA_PLACEHOLDER_SECRET } from "../../../packages/connections/src/credential-test.ts"; +import { OLLAMA_PLACEHOLDER_SECRET } from "../../packages/connections/src/credential-test.ts"; const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); const WEB_DIR = path.join(REPO_ROOT, "apps", "web"); diff --git a/scripts/e2e/database-url.ts b/e2e/database-url.ts similarity index 98% rename from scripts/e2e/database-url.ts rename to e2e/database-url.ts index d79b995f71..fd70ea727d 100644 --- a/scripts/e2e/database-url.ts +++ b/e2e/database-url.ts @@ -1,4 +1,4 @@ -// The env-gate half of scripts/e2e/harness.ts, split out so a DB-gated +// The env-gate half of e2e/harness.ts, split out so a DB-gated // unit suite (every package's migrations.test.ts / *.drizzle.test.ts) // can resolve DATABASE_URL without pulling in harness.ts's process- // spawning machinery — which imports @corbits/connections and diff --git a/scripts/e2e/db-gate.ts b/e2e/db-gate.ts similarity index 100% rename from scripts/e2e/db-gate.ts rename to e2e/db-gate.ts diff --git a/scripts/e2e/harness.ts b/e2e/harness.ts similarity index 99% rename from scripts/e2e/harness.ts rename to e2e/harness.ts index 08a5ef3061..e5ad50ded4 100644 --- a/scripts/e2e/harness.ts +++ b/e2e/harness.ts @@ -147,7 +147,7 @@ export async function connectE2eDb(databaseUrl: string): Promise { * connect-and-immediately-reject loop, not a usable dial-in. Every * suite this repo runs against CI now lets the hub's own * process-provisioner spawn a dedicated sidecar per allocation instead - * (see `startHub`'s doc comment and `scripts/e2e/chat.test.ts`); this + * (see `startHub`'s doc comment and `e2e/chat.test.ts`); this * function is kept only for the handful of scripts not yet migrated off * a directly-controlled sidecar process (e.g. ones that kill and * restart a specific sidecar mid-scenario) and is not a working dial-in diff --git a/scripts/e2e/noop-inference-server.ts b/e2e/noop-inference-server.ts similarity index 97% rename from scripts/e2e/noop-inference-server.ts rename to e2e/noop-inference-server.ts index 2096d05813..265a1a30a5 100644 --- a/scripts/e2e/noop-inference-server.ts +++ b/e2e/noop-inference-server.ts @@ -19,7 +19,7 @@ // earn by producing no text, so nothing downstream mistakes the // constant for a real answer. -import type { ApiCall } from "../../packages/hub-api-client/src/index.ts"; +import type { ApiCall } from "../packages/hub-api-client/src/index.ts"; import { ensureCatalogModel, ensureCatalogOffering, @@ -28,7 +28,7 @@ import { ensureProvider, inferenceCredentialName, PLACEHOLDER_CATALOG_API_KEY, -} from "../../packages/connections/src/seed-catalog.ts"; +} from "../packages/connections/src/seed-catalog.ts"; const NOOP_REPLY_TEXT = ""; diff --git a/scripts/e2e/smoke-auth.test.ts b/e2e/smoke-auth.test.ts similarity index 98% rename from scripts/e2e/smoke-auth.test.ts rename to e2e/smoke-auth.test.ts index 012ef5a046..51f7af0c11 100644 --- a/scripts/e2e/smoke-auth.test.ts +++ b/e2e/smoke-auth.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; import { api, createCleanupHarness, diff --git a/scripts/e2e/smoke-webhook.test.ts b/e2e/smoke-webhook.test.ts similarity index 98% rename from scripts/e2e/smoke-webhook.test.ts rename to e2e/smoke-webhook.test.ts index 4fd49c5b29..b509b9a863 100644 --- a/scripts/e2e/smoke-webhook.test.ts +++ b/e2e/smoke-webhook.test.ts @@ -22,14 +22,14 @@ import { } from "@corbits/webhook-triggers"; import { describe, expect, test } from "bun:test"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; import { buildAssistantWorkflow, serializeAssistantWorkflow, -} from "../../agents/assistant/src/index.ts"; +} from "../agents/assistant/src/index.ts"; import { ensureNoopCatalogOffering, startNoopInferenceServer } from "./noop-inference-server.ts"; -import { publishCorbitsToolsRegistry } from "../../packages/tool-registry-publish/src/publish.ts"; -import { createHubAPI } from "../../packages/hub-api-client/src/index.ts"; +import { publishCorbitsToolsRegistry } from "../packages/tool-registry-publish/src/publish.ts"; +import { createHubAPI } from "../packages/hub-api-client/src/index.ts"; import { api, connectE2eDb, diff --git a/scripts/e2e/t3-github-roundtrip.proof.test.ts b/e2e/t3-github-roundtrip.proof.test.ts similarity index 98% rename from scripts/e2e/t3-github-roundtrip.proof.test.ts rename to e2e/t3-github-roundtrip.proof.test.ts index 8bc47b2080..04ad5b7bc6 100644 --- a/scripts/e2e/t3-github-roundtrip.proof.test.ts +++ b/e2e/t3-github-roundtrip.proof.test.ts @@ -8,7 +8,7 @@ // database URL is set or no gh auth is available (e.g. CI without a token). import { describe, expect, test } from "bun:test"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; import { api, createCleanupHarness, diff --git a/scripts/e2e/walking-skeleton.test.ts b/e2e/walking-skeleton.test.ts similarity index 98% rename from scripts/e2e/walking-skeleton.test.ts rename to e2e/walking-skeleton.test.ts index c7635b72b0..ffddf398af 100644 --- a/scripts/e2e/walking-skeleton.test.ts +++ b/e2e/walking-skeleton.test.ts @@ -21,13 +21,13 @@ import { describe, expect, test } from "bun:test"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; +import { resetSchema, setupDatabase } from "../scripts/db-setup.ts"; import { buildAssistantWorkflow, serializeAssistantWorkflow, -} from "../../agents/assistant/src/index.ts"; -import { publishCorbitsToolsRegistry } from "../../packages/tool-registry-publish/src/publish.ts"; -import { createHubAPI } from "../../packages/hub-api-client/src/index.ts"; +} from "../agents/assistant/src/index.ts"; +import { publishCorbitsToolsRegistry } from "../packages/tool-registry-publish/src/publish.ts"; +import { createHubAPI } from "../packages/hub-api-client/src/index.ts"; import { api, assertNeverRealProvider, diff --git a/scripts/e2e/workflow-source.ts b/e2e/workflow-source.ts similarity index 93% rename from scripts/e2e/workflow-source.ts rename to e2e/workflow-source.ts index 2d169c277a..41c6ae55e6 100644 --- a/scripts/e2e/workflow-source.ts +++ b/e2e/workflow-source.ts @@ -4,8 +4,8 @@ // Most harness consumers (spawning the hub/sidecar, calling its HTTP // API) never touch these two; keeping them here means importing // harness.ts no longer pays that load cost on their behalf. -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { WORKFLOW_SOURCE_ENTRY } from "../../packages/workflows/src/source.ts"; +import { createGitWorkflowPusher } from "../packages/connections/src/workflow-push.ts"; +import { WORKFLOW_SOURCE_ENTRY } from "../packages/workflows/src/source.ts"; /** * Publishes a workflow definition into its asset repo in the one shape diff --git a/package.json b/package.json index 134938f147..2c5bc26d19 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,12 @@ "scripts": { "check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test", "typecheck": "bun run scripts/typecheck.ts", - "lint": "oxlint apps packages tools agents skills workflows scripts test", - "fmt": "oxfmt apps packages tools agents skills workflows scripts test", - "fmt:check": "oxfmt --check apps packages tools agents skills workflows scripts test", - "hooks:install": "bun run scripts/hooks-install.ts", - "prepare": "bun run hooks:install", + "lint": "oxlint apps packages tools agents skills workflows scripts test e2e", + "fmt": "oxfmt apps packages tools agents skills workflows scripts test e2e", + "fmt:check": "oxfmt --check apps packages tools agents skills workflows scripts test e2e", "build": "bun run scripts/run-all.ts build", - "test": "bun test ./scripts/*.test.ts && bun run scripts/run-all.ts test", - "test:e2e": "bun test scripts/e2e --max-concurrency=1", + "test": "bun run scripts/run-all.ts test", + "test:e2e": "bun test e2e --max-concurrency=1", "dev": "bun run scripts/dev.ts", "publish-tools": "bun scripts/publish-tools.ts", "setup": "bun scripts/db-setup.ts", diff --git a/packages/agent-directory/test/routes.integration.test.ts b/packages/agent-directory/test/routes.integration.test.ts index b34299c63a..e5d38eba50 100644 --- a/packages/agent-directory/test/routes.integration.test.ts +++ b/packages/agent-directory/test/routes.integration.test.ts @@ -41,7 +41,7 @@ import { createAgentDefinitionRoutes } from "../src/routes"; import type { PinnedSkillIndexResolver } from "../src/routes"; import type { DefinitionAssetHistory } from "../src/definition-history"; import type { CapabilityInventoryProvider } from "../src/capability-inventory"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { dbGate } from "../../../e2e/db-gate"; const databaseUrl = process.env["DATABASE_URL"]; const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/packages/agent-directory/test/tool-package-version.drizzle.test.ts b/packages/agent-directory/test/tool-package-version.drizzle.test.ts index 30c3f71a3e..b81a97ec9a 100644 --- a/packages/agent-directory/test/tool-package-version.drizzle.test.ts +++ b/packages/agent-directory/test/tool-package-version.drizzle.test.ts @@ -18,8 +18,8 @@ import type { AssetService } from "@intx/hub-sessions"; import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../e2e/database-url"; +import { dbGate } from "../../../e2e/db-gate"; import { CapabilityOutOfInventoryError } from "../src/capability-inventory"; import { resolvePinnedVersion } from "../src/tool-package-version"; diff --git a/packages/agent-directory/test/visible-definitions.drizzle.test.ts b/packages/agent-directory/test/visible-definitions.drizzle.test.ts index 0015b3282f..6aa3f23be2 100644 --- a/packages/agent-directory/test/visible-definitions.drizzle.test.ts +++ b/packages/agent-directory/test/visible-definitions.drizzle.test.ts @@ -12,9 +12,9 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; import { createDB, runMigrations, dropSchema, schema } from "@intx/db"; import { dbTargetFromUrl } from "../../../scripts/db-setup"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; +import { e2eDatabaseUrl } from "../../../e2e/database-url"; import { listVisibleAgentDefinitions } from "../src/visible-definitions"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { dbGate } from "../../../e2e/db-gate"; const databaseUrl = e2eDatabaseUrl(); const describeIfDb = dbGate(databaseUrl, import.meta.path); diff --git a/packages/connections/src/connect-github-setup.race.test.ts b/packages/connections/src/connect-github-setup.race.test.ts index ce94d59bcb..90e04772ac 100644 --- a/packages/connections/src/connect-github-setup.race.test.ts +++ b/packages/connections/src/connect-github-setup.race.test.ts @@ -34,8 +34,8 @@ import { } from "@corbits/webhook-triggers"; import type { GitHubRepoSummary } from "@corbits/github-tools"; -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { dbGate } from "../../../scripts/e2e/db-gate"; +import { e2eDatabaseUrl } from "../../../e2e/database-url"; +import { dbGate } from "../../../e2e/db-gate"; import { startReviewingRepos, webhookTriggerName, diff --git a/packages/connections/src/seed-catalog.ts b/packages/connections/src/seed-catalog.ts index b60bb8624d..ac12c92191 100644 --- a/packages/connections/src/seed-catalog.ts +++ b/packages/connections/src/seed-catalog.ts @@ -769,7 +769,7 @@ export async function seedCatalog(args: SeedCatalogArgs): Promise { - expect(DIGEST_HANDOFF_SQL).toMatch(/updated_at\s*=\s*now\(\)/); -}); - -const OLD_NUMBERING_TAIL = [ - "0084_delete_orphaned_credential_grants.sql", - "0085_add_approval_run_idx.sql", - "0086_workflow_definition_version_wire_projection.sql", - "0087_workflow_definition_origin.sql", - "0088_workflow_definition_version_tolerate_body_failure.sql", -]; - -describeIfDb("setupDatabase against a schema migrated under the old numbering", () => { - test("refuses to apply incrementally and names the reset", async () => { - const schema = `db_setup_test_${Date.now().toString(36)}`; - const target = dbTargetFromUrl(databaseUrl); - const sql = await loadPostgres().then((postgres) => - postgres({ ...target, max: 1, onnotice: () => undefined }), - ); - try { - await sql.unsafe(`CREATE SCHEMA "${schema}"`); - await sql.unsafe( - `CREATE TABLE "${schema}"."workbench_setup_migration" (filename text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - for (const file of OLD_NUMBERING_TAIL) { - await sql.unsafe( - `INSERT INTO "${schema}"."workbench_setup_migration" (filename) VALUES ($1)`, - [file], - ); - } - await expect(setupDatabase(databaseUrl, { schema })).rejects.toThrow( - /different @intx\/db migration set[\s\S]*db-setup\.ts --reset/, - ); - } finally { - await sql.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); - await sql.end(); - } - }); -}); diff --git a/scripts/e2e/db-gate.test.ts b/scripts/e2e/db-gate.test.ts deleted file mode 100644 index 555cafe006..0000000000 --- a/scripts/e2e/db-gate.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Unit coverage for dbGate: a missing database must never skip in -// total silence. Locally (CI unset) it returns describe.skip and the -// summary names `compose.test.yml`. Under CI=true it throws -// instead, so a miswired pipeline cannot report green on a suite that -// never ran. None of ci.yml's jobs provision Postgres; they -// all still skip with the same compose hint. - -import { afterEach, describe, expect, test } from "bun:test"; -import { databaseIsRequired, dbGate } from "./db-gate.ts"; - -const gatedKeys = ["CI", "GITHUB_JOB", "E2E_REQUIRED"] as const; -const saved = new Map(gatedKeys.map((key) => [key, process.env[key]])); - -/** Unsets a gated variable for one test. Empty string is not `CI=true` - * and is not a named GITHUB_JOB, same as a variable the shell never set. */ -function unset(key: (typeof gatedKeys)[number]): void { - process.env[key] = ""; -} - -afterEach(() => { - for (const key of gatedKeys) { - process.env[key] = saved.get(key) ?? ""; - } -}); - -describe("databaseIsRequired", () => { - test("is false when CI is unset, even if E2E_REQUIRED=1", () => { - unset("CI"); - unset("GITHUB_JOB"); - process.env["E2E_REQUIRED"] = "1"; - expect(databaseIsRequired()).toBe(false); - }); - - test("is true when CI=true and the job is not a named ci.yml job", () => { - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(databaseIsRequired()).toBe(true); - process.env["GITHUB_JOB"] = "some-other-job"; - expect(databaseIsRequired()).toBe(true); - }); - - test("is false on every ci.yml job — none of them provision Postgres", () => { - process.env["CI"] = "true"; - for (const job of ["setup", "lint", "typecheck", "structural", "unit"]) { - process.env["GITHUB_JOB"] = job; - expect(databaseIsRequired()).toBe(false); - } - }); -}); - -describe("dbGate", () => { - test("returns describe when a database URL is configured", () => { - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(dbGate("postgres://localhost:5432/workbench", "example")).toBe(describe); - }); - - test("returns describe.skip when no database is configured and CI is unset", () => { - unset("CI"); - unset("GITHUB_JOB"); - expect(dbGate(undefined, "example")).toBe(describe.skip); - expect(dbGate("", "example")).toBe(describe.skip); - }); - - test("does not treat E2E_REQUIRED as a required-ness signal", () => { - unset("CI"); - unset("GITHUB_JOB"); - process.env["E2E_REQUIRED"] = "1"; - expect(dbGate(undefined, "example")).toBe(describe.skip); - }); - - test("throws when CI=true but no database is configured", () => { - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(() => dbGate(undefined, "example-suite")).toThrow(/example-suite/); - expect(() => dbGate(undefined, "example-suite")).toThrow( - /docker compose -f compose\.test\.yml up -d/, - ); - expect(() => dbGate("", "example-suite")).toThrow( - /postgres:\/\/postgres:postgres@localhost:5432\/workbench/, - ); - }); - - test("does not throw under CI=true once a database is configured", () => { - process.env["CI"] = "true"; - unset("GITHUB_JOB"); - expect(dbGate("postgres://localhost:5432/workbench", "example")).toBe(describe); - }); - - test("skips on the GitHub unit job even when CI=true", () => { - process.env["CI"] = "true"; - process.env["GITHUB_JOB"] = "unit"; - expect(dbGate(undefined, "example")).toBe(describe.skip); - }); -}); diff --git a/scripts/e2e/db-setup.test.ts b/scripts/e2e/db-setup.test.ts deleted file mode 100644 index 37b5fa4ccd..0000000000 --- a/scripts/e2e/db-setup.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Unit gates for scripts/db-setup.ts against a real Postgres: the -// wiring this repo owns — shipped-migration discovery out of the -// vendored @intx/db, the setup ledger, idempotent re-runs, the loud -// mismatch failure with its documented reset fix. The migrations themselves are Interchange's to -// test; nothing here asserts on schema contents beyond the rows this -// script writes. -// -// The suite owns a uniquely-named sibling scratch database (created by -// setupDatabase on first use, dropped in teardown) so it never touches -// the developer's own database. Like the walking skeleton it skips -// without DATABASE_URL, and CI=true turns that skip into a loud -// failure. - -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { readdir } from "node:fs/promises"; -import path from "node:path"; - -import { dbTargetFromUrl, resetSchema, setupDatabase } from "../db-setup.ts"; -import { assertDatabaseConfigured, skippedDatabaseWarning } from "./db-gate.ts"; - -const REPO_ROOT = path.resolve(import.meta.dir, "..", ".."); -const HUB_DIR = path.join(REPO_ROOT, "apps", "hub"); -const VENDORED_MIGRATIONS_DIR = path.join(REPO_ROOT, "vendor", "intx", "db", "migrations"); - -function scratchDatabaseUrl(): string | undefined { - const base = process.env["DATABASE_URL"]; - if (base === undefined || base === "") { - assertDatabaseConfigured(undefined, "db-setup suite"); - return undefined; - } - const url = new URL(base); - const database = url.pathname.replace(/^\//, ""); - if (database === "") { - throw new Error(`DATABASE_URL names no database (empty path): ${base}`); - } - const suffix = crypto.randomUUID().slice(0, 8); - url.pathname = `/${database}_dbsetup_${suffix}`; - return url.toString(); -} - -const databaseUrl = scratchDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn(skippedDatabaseWarning("db-setup")); -} - -// Minimal local view of the postgres client, resolved out of the hub's -// dependency tree exactly like scripts/db-setup.ts resolves it. -interface SqlClient { - unsafe(query: string, params?: unknown[]): Promise[]>; - end(): Promise; -} - -async function connectTo(url: string, database?: string): Promise { - const resolved = Bun.resolveSync("postgres", HUB_DIR); - const { default: postgres } = (await import(resolved)) as { - default: (options: Record) => SqlClient; - }; - const target = dbTargetFromUrl(url); - return postgres({ - host: target.host, - port: target.port, - user: target.user, - password: target.password, - database: database ?? target.database, - max: 1, - onnotice: () => undefined, - }); -} - -describe.skipIf(databaseUrl === undefined)("db-setup", () => { - const url = databaseUrl as string; - const databaseLifecycleTimeoutMs = 30_000; - - // The suite owns the one expensive fresh apply; every test starts - // from a fully migrated scratch database instead of relying on a - // previous test's side effects. - let freshApply: Awaited>; - - beforeAll(async () => { - freshApply = await setupDatabase(url); - }, databaseLifecycleTimeoutMs); - - afterAll(async () => { - const maintenance = await connectTo(url, "postgres"); - try { - const database = dbTargetFromUrl(url).database; - await maintenance.unsafe( - `DROP DATABASE IF EXISTS "${database.replace(/"/g, '""')}" WITH (FORCE)`, - ); - } finally { - await maintenance.end(); - } - }, databaseLifecycleTimeoutMs); - - test("fresh database applies every vendored migration and records it", async () => { - const shippedOnDisk = (await readdir(VENDORED_MIGRATIONS_DIR)) - .filter((f) => f.endsWith(".sql")) - .sort(); - expect(shippedOnDisk.length).toBeGreaterThan(0); - - expect(freshApply.createdDatabase).toBe(true); - expect(freshApply.action).toBe("migrated"); - expect(freshApply.migrations).toBe(shippedOnDisk.length); - - const sql = await connectTo(url); - try { - const rows = await sql.unsafe( - `SELECT filename FROM "public"."workbench_setup_migration" ORDER BY filename`, - ); - expect(rows.map((r) => String(r["filename"]))).toEqual(shippedOnDisk); - } finally { - await sql.end(); - } - }); - - test("installed package migrations create the notify and mailbox tables", async () => { - const sql = await connectTo(url); - try { - const notifyDispatch = await sql.unsafe( - `SELECT 1 FROM information_schema.tables - WHERE table_schema = 'notify' AND table_name = 'notify_dispatch'`, - ); - expect(notifyDispatch).toHaveLength(1); - - const mailboxTables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables - WHERE table_schema = 'mailbox' - ORDER BY table_name`, - ); - expect(mailboxTables.map((r) => String(r["table_name"]))).toEqual([ - "corbits_mailbox_migrations", - "mailbox", - "principal_mail", - ]); - } finally { - await sql.end(); - } - }); - - test( - "resetSchema drops the mailbox package's own schema, not only the platform's", - async () => { - await resetSchema(url); - const sql = await connectTo(url); - try { - const mailboxSchema = await sql.unsafe( - `SELECT 1 FROM information_schema.schemata WHERE schema_name = 'mailbox'`, - ); - expect(mailboxSchema).toHaveLength(0); - } finally { - await sql.end(); - } - - // Leave the scratch database migrated again for any later test in this - // file (and to prove the installed packages' migrations replay cleanly - // after a reset, not only on a first-ever apply). - const rebuilt = await setupDatabase(url); - expect(rebuilt.action).toBe("migrated"); - }, - databaseLifecycleTimeoutMs, - ); - - test("re-run on a current schema reports unchanged and touches nothing", async () => { - const first = await setupDatabase(url); - expect(first.action).toBe("unchanged"); - const second = await setupDatabase(url); - expect(second.action).toBe("unchanged"); - expect(second.migrations).toBe(first.migrations); - }); - - test( - "a ledger that disagrees with the shipped set fails loudly; reset recovers", - async () => { - // Simulate a database set up under an older migration set (e.g. a - // pre-fold dev database) by shortening the recorded ledger. - const sql = await connectTo(url); - try { - await sql.unsafe( - `DELETE FROM "public"."workbench_setup_migration" - WHERE filename = (SELECT max(filename) FROM "public"."workbench_setup_migration")`, - ); - } finally { - await sql.end(); - } - - await expect(setupDatabase(url)).rejects.toThrow( - /different @intx\/db migration set[\s\S]*--reset/, - ); - - // The failure names the fix; prove the fix works. - await resetSchema(url); - const rebuilt = await setupDatabase(url); - expect(rebuilt.action).toBe("migrated"); - }, - databaseLifecycleTimeoutMs, - ); -}); diff --git a/scripts/e2e/docker-sidecar-smoke.ts b/scripts/e2e/docker-sidecar-smoke.ts deleted file mode 100644 index 51a2be4204..0000000000 --- a/scripts/e2e/docker-sidecar-smoke.ts +++ /dev/null @@ -1,166 +0,0 @@ -// drives the real @corbits/docker-provisioner plugin against a -// live Docker daemon — create -> exclusive-run (container appears) -> -// release -> container gone. This is the one proof of the docker sidecar -// provisioner that needs real infrastructure, so it lives outside `bun -// test`: it never runs in CI, and it exits 0 with a skip message rather -// than failing when no daemon is reachable, so a fresh checkout without -// Docker installed stays green everywhere else. -// -// Run manually: `bun run scripts/e2e/docker-sidecar-smoke.ts` -// Point it at a real sidecar image with DOCKER_PROVISIONER_IMAGE (the -// same variable the hub reads); it otherwise runs a throwaway public -// image that never connects to a hub — this proof only exercises the -// provisioner's container lifecycle, not a real sidecar boot. -// -// Set DOCKER_SIDECAR_IMAGE to opt into real-image mode: the smoke test -// runs that image instead of the throwaway alpine default, and adds a -// liveness assertion — the container must still be running after -// DOCKER_SIDECAR_SMOKE_STAY_UP_SECONDS (default 5) — to catch a real -// sidecar image that boots and then immediately crash-loops. Default -// alpine lifecycle mode is unchanged: no liveness wait is added there. - -import { randomUUID } from "node:crypto"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { createDockerSidecarProvisioner } from "../../packages/docker-provisioner/src/index"; - -const DEFAULT_IMAGE = "docker.io/library/alpine:3.19"; -const LOG_PREFIX = "[docker-sidecar-smoke]"; - -async function dockerAvailable(): Promise { - try { - const proc = Bun.spawn(["docker", "version", "--format", "{{.Server.Version}}"], { - stdout: "pipe", - stderr: "pipe", - }); - const exitCode = await proc.exited; - return exitCode === 0; - } catch { - return false; - } -} - -async function containerLabeled( - allocationId: string, - sidecarId: string, - extraFilters: readonly string[] = [], -): Promise { - const proc = Bun.spawn( - [ - "docker", - "ps", - "-aq", - "--filter", - `label=corbits.allocationId=${allocationId}`, - "--filter", - `label=corbits.sidecarId=${sidecarId}`, - ...extraFilters, - ], - { stdout: "pipe", stderr: "pipe" }, - ); - const stdout = await new Response(proc.stdout).text(); - await proc.exited; - return stdout.trim().length > 0; -} - -async function containerRunning(allocationId: string, sidecarId: string): Promise { - return containerLabeled(allocationId, sidecarId, ["--filter", "status=running"]); -} - -async function main(): Promise { - if (!(await dockerAvailable())) { - console.log( - `${LOG_PREFIX} docker not available — skipping. Install/start Docker ` + - "(or point $DOCKER_HOST at a reachable daemon) to run this proof.", - ); - return; - } - - const realImage = process.env["DOCKER_SIDECAR_IMAGE"]; - const image = - realImage ?? - process.env["DOCKER_SIDECAR_SMOKE_IMAGE"] ?? - process.env["DOCKER_PROVISIONER_IMAGE"] ?? - DEFAULT_IMAGE; - const stateDir = await mkdtemp(path.join(tmpdir(), "docker-sidecar-smoke-")); - const provisioner = createDockerSidecarProvisioner({ - config: { image, stateFilePath: path.join(stateDir, "state.json") }, - }); - - const allocationId = `alloc_smoke_${randomUUID()}`; - const sidecarId = `sc_smoke_${randomUUID()}`; - const request = { - allocationId, - generation: 1, - tenantId: "tnt_smoke", - anchorRunId: "run_smoke", - sidecarId, - token: randomUUID(), - // Unreachable on purpose: this proof only exercises the container - // lifecycle, not a real sidecar connecting back to a hub. - hubWebSocketUrl: "ws://127.0.0.1:1/api/sidecars/ws", - }; - - try { - console.log(`${LOG_PREFIX} create: requesting a sidecar container from ${image}`); - const ensureResult = await provisioner.ensure(request); - if (ensureResult.kind !== "accepted") { - throw new Error(`ensure was rejected: ${ensureResult.code}: ${ensureResult.message}`); - } - console.log( - `${LOG_PREFIX} exclusive-run: accepted, externalRef=${ensureResult.externalRef ?? "(none)"}`, - ); - - if (!(await containerLabeled(allocationId, sidecarId))) { - throw new Error( - "container-appears check failed: no container found with the allocationId/sidecarId labels", - ); - } - console.log(`${LOG_PREFIX} container-appears: confirmed via docker ps -a`); - - if (realImage !== undefined) { - const stayUpSeconds = Number(process.env["DOCKER_SIDECAR_SMOKE_STAY_UP_SECONDS"] ?? "5"); - console.log( - `${LOG_PREFIX} real-image mode: asserting the container stays up for ${String(stayUpSeconds)}s`, - ); - await new Promise((resolve) => setTimeout(resolve, stayUpSeconds * 1000)); - if (!(await containerRunning(allocationId, sidecarId))) { - throw new Error( - `real-image liveness check failed: container exited before ${String(stayUpSeconds)}s elapsed`, - ); - } - console.log( - `${LOG_PREFIX} real-image: container still running after ${String(stayUpSeconds)}s`, - ); - } - - const destroyResult = await provisioner.destroy({ - allocationId, - generation: request.generation, - sidecarId, - ...(ensureResult.externalRef !== undefined ? { externalRef: ensureResult.externalRef } : {}), - }); - if (destroyResult.kind !== "destroyed") { - throw new Error(`release failed: ${destroyResult.code}: ${destroyResult.message}`); - } - console.log(`${LOG_PREFIX} release: destroy() reported destroyed`); - - if (await containerLabeled(allocationId, sidecarId)) { - throw new Error( - "container-gone check failed: a container with these labels is still present", - ); - } - console.log(`${LOG_PREFIX} container-gone: confirmed — full lifecycle proof passed`); - } finally { - await rm(stateDir, { recursive: true, force: true }); - } -} - -main().catch((error: unknown) => { - console.error( - `${LOG_PREFIX} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, - ); - process.exit(1); -}); diff --git a/scripts/e2e/harness.test.ts b/scripts/e2e/harness.test.ts deleted file mode 100644 index 64165e019d..0000000000 --- a/scripts/e2e/harness.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Coverage for the cleanup registry every e2e/smoke suite in this -// directory shares: `tempDir` really mkdtemps under the OS -// temp dir with the given prefix, and `track` accepts a spawned app -// without requiring its `stop()` to fire immediately (that only -// happens once the suite's own tests finish, via `afterAll`). - -import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import path from "node:path"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { - createCleanupHarness, - e2eDatabaseUrl, - parseEnvFileDatabaseUrl, - provisionSidecar, - runCleanups, - type SpawnedApp, -} from "./harness.ts"; -import { workflowDeployBody } from "./workflow-source.ts"; - -describe("createCleanupHarness", () => { - test("tempDir mkdtemps a real directory named with its prefix", async () => { - const { tempDir } = createCleanupHarness(); - const dir = await tempDir("harness-unit-test-"); - expect(existsSync(dir)).toBe(true); - expect(dir).toContain("harness-unit-test-"); - }); - - test("two tempDir calls never collide", async () => { - const { tempDir } = createCleanupHarness(); - const [first, second] = await Promise.all([ - tempDir("harness-unit-test-"), - tempDir("harness-unit-test-"), - ]); - expect(first).not.toBe(second); - expect(existsSync(first)).toBe(true); - expect(existsSync(second)).toBe(true); - }); - - test("track accepts a spawned app without calling stop() itself", () => { - const { track } = createCleanupHarness(); - let stopped = false; - const app: SpawnedApp = { - label: "fake", - output: () => "", - exited: () => false, - stop: async () => { - stopped = true; - }, - }; - track(app); - expect(stopped).toBe(false); - }); - - test("a throwing cleanup does not abort the rest; first failure rethrows", async () => { - const ran: string[] = []; - const cleanups: (() => Promise | void)[] = [ - () => { - ran.push("first-registered"); - }, - () => { - throw new Error("boom"); - }, - () => { - ran.push("last-registered"); - }, - ]; - await expect(runCleanups(cleanups)).rejects.toThrow("boom"); - expect(ran).toEqual(["last-registered", "first-registered"]); - expect(cleanups).toHaveLength(0); - }); -}); - -describe("workflowDeployBody", () => { - test("carries catalog offering ids, never a raw provider/apiKey source", () => { - const body = workflowDeployBody({ - assetId: "asset-1", - commitSha: "deadbeef", - sourceOfferingIds: ["offering-1", "offering-2"], - defaultSourceOfferingId: "offering-1", - }); - expect(body).toEqual({ - source: { - kind: "asset", - assetId: "asset-1", - package: { format: "source", commitSha: "deadbeef" }, - }, - entry: (body as { entry: string }).entry, - sourceOfferingIds: ["offering-1", "offering-2"], - defaultSourceOfferingId: "offering-1", - }); - }); -}); - -describe("parseEnvFileDatabaseUrl", () => { - test("reads DATABASE_URL and ignores comments and blanks", () => { - expect( - parseEnvFileDatabaseUrl( - "# comment\n\nFOO=bar\nDATABASE_URL=postgres://localhost:5432/workbench\n", - ), - ).toBe("postgres://localhost:5432/workbench"); - }); - - test("strips surrounding quotes and trims", () => { - expect( - parseEnvFileDatabaseUrl(` DATABASE_URL="postgres://localhost:5432/workbench" \n`), - ).toBe("postgres://localhost:5432/workbench"); - expect(parseEnvFileDatabaseUrl(`DATABASE_URL='postgres://localhost:5432/workbench'\n`)).toBe( - "postgres://localhost:5432/workbench", - ); - }); - - test("ignores a commented DATABASE_URL and returns undefined when none is set", () => { - expect( - parseEnvFileDatabaseUrl("# DATABASE_URL=postgres://commented\nFOO=bar\n"), - ).toBeUndefined(); - }); - - test("last DATABASE_URL wins", () => { - expect( - parseEnvFileDatabaseUrl( - "DATABASE_URL=postgres://first/db\nDATABASE_URL=postgres://second/db\n", - ), - ).toBe("postgres://second/db"); - }); -}); - -// regression: at pin 692c3106, the hub authenticates a -// sidecar's register/reconnect frame only against a `sidecar` row that -// resolves to a live `sidecar_allocation` (or `workflow_probe`) row — -// upstream dropped the older "shared" credential scope this repo used -// to provision a single, standalone sidecar identity for (see -// `provisionSidecar`'s own doc comment). CI's e2e suites hit exactly -// this: a `provisionSidecar`-only row connects and is rejected with -// "invalid token" in a loop (never a working dial-in), which is why -// every suite now lets the hub's own process-provisioner spawn a -// dedicated, already-allocated sidecar per deployment instead (see -// `startHub`'s doc comment). This test pins the negative: an -// unallocated identity must keep failing to authenticate, so a future -// upstream re-vendor that silently restores (or a caller that -// silently starts relying on) the old "shared" scope surfaces here -// first rather than as a mystery disconnect loop in CI. -describe("provisionSidecar", () => { - const databaseUrl = e2eDatabaseUrl(); - const HUB_DIR = path.resolve(import.meta.dir, "..", "..", "apps", "hub"); - - test.skipIf(databaseUrl === undefined)( - "a provisioned identity with no allocation or probe never authenticates", - async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await resetSchema(url); - await setupDatabase(url); - - const sidecarId = "harness-test-unallocated-sidecar"; - const token = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, token); - - // Resolved through the hub's own dependency tree, exactly as - // `connectE2eDb` resolves `postgres` above — scripts/e2e is not - // itself a workspace member of these `@intx/*` packages. - const dbModule = (await import(Bun.resolveSync("@intx/db", HUB_DIR))) as { - createDB: (raw: unknown) => { db: unknown; close: () => Promise }; - }; - const hubSessionsModule = (await import(Bun.resolveSync("@intx/hub-sessions", HUB_DIR))) as { - createSidecarCredentialResolver: (deps: { db: unknown }) => { - resolve: (token: string) => Promise; - }; - }; - - const parsed = new URL(url); - const db = dbModule.createDB({ - host: parsed.hostname, - port: parsed.port === "" ? 5432 : Number(parsed.port), - user: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - database: parsed.pathname.replace(/^\//, ""), - }); - try { - const resolver = hubSessionsModule.createSidecarCredentialResolver({ - db: db.db, - }); - const identity = await resolver.resolve(token); - expect(identity).toBeNull(); - } finally { - await db.close(); - } - }, - 60_000, - ); -}); diff --git a/scripts/e2e/list-suites.test.ts b/scripts/e2e/list-suites.test.ts deleted file mode 100644 index 8a932908ca..0000000000 --- a/scripts/e2e/list-suites.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { listSuites } from "./list-suites.ts"; - -test("listSuites includes a fixture suite and folds the unit files together", async () => { - const dir = await mkdtemp(join(tmpdir(), "e2e-list-suites-")); - try { - for (const name of [ - "chat.test.ts", - "harness.test.ts", - "db-gate.test.ts", - "db-setup.test.ts", - "not-a-suite.ts", - ]) { - await writeFile(join(dir, name), ""); - } - - const suites = await listSuites(dir); - - expect(suites).toContain("chat"); - expect(suites).toContain("unit"); - expect(suites).not.toContain("harness"); - expect(suites).not.toContain("db-gate"); - expect(suites).not.toContain("db-setup"); - expect(suites).not.toContain("list-suites"); - expect(suites).not.toContain("not-a-suite"); - expect(suites.filter((suite) => suite === "unit")).toHaveLength(1); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/scripts/e2e/list-suites.ts b/scripts/e2e/list-suites.ts deleted file mode 100644 index acac1af2ff..0000000000 --- a/scripts/e2e/list-suites.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Prints a JSON array of e2e suite ids for the CI matrix: one id per -// scripts/e2e/*.test.ts file, except harness.test.ts, db-gate.test.ts, and -// db-setup.test.ts, which fold into a single "unit" entry (they cover pure -// helpers rather than a full hub+sidecar boot, so a single fast job covers -// all three instead of paying three boot costs). -import { readdir } from "node:fs/promises"; -import { join } from "node:path"; - -export const UNIT_SUITE_FILES = ["harness", "db-gate", "db-setup", "list-suites"] as const; - -export async function listSuites(e2eDir: string): Promise { - const entries = await readdir(e2eDir, { withFileTypes: true }); - const suiteNames = entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".test.ts")) - .map((entry) => entry.name.slice(0, -".test.ts".length)); - - const unitFiles = new Set(UNIT_SUITE_FILES); - const suites = suiteNames.filter((name) => !unitFiles.has(name)); - if (suiteNames.some((name) => unitFiles.has(name))) suites.push("unit"); - - return suites.sort(); -} - -if (import.meta.main) { - const suites = await listSuites(join(import.meta.dir)); - console.log(JSON.stringify(suites)); -} diff --git a/scripts/e2e/smoke-chat.test.ts b/scripts/e2e/smoke-chat.test.ts deleted file mode 100644 index f9f4fb17c6..0000000000 --- a/scripts/e2e/smoke-chat.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Smoke scenario 3/5: chat round-trip. A workbench is created, -// a message is posted and read back with its content intact, and the -// workbench's invited-agent listing answers with the documented shape. -// This deliberately stops short of `chat.test.ts`'s full battery -// (mentions, read-state, multi-principal fan-out, agent invite) — it -// is a fast canary that the basic surface is alive, not a re-run of -// the deep suite. No workflow is deployed and no inference source is -// configured: the invitable-definitions listing is asserted only on -// its documented shape (an `items` array of `{id, name}`) rather than -// by exercising a live agent invite, so this scenario never needs a -// credential of any kind. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type HubHandle, -} from "./harness.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "smoke-chat: DATABASE_URL is not set; suite skipped. Set DATABASE_URL " + - "(see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` so this skip " + - "can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -describe.skipIf(databaseUrl === undefined)("smoke: chat round-trip", () => { - test("a posted message persists and the invited-agent listing has the documented shape", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - await setupDatabase(url); - }); - - const hubDataDir = await tempDir("e2e-smoke-chat-hub-data-"); - - const hub: HubHandle = await hop("hub boot", () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: hubDataDir, - }), - ); - track(hub); - - { - const cookies = await hop("sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Chat Smoke Tester", - email: `smoke-chat-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res.cookies; - }); - - const tenantId = await hop("tenant creation", async () => { - const slug = `smokechat${crypto.randomUUID().slice(0, 8)}`; - const res = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { name: "Chat Smoke", slug }, - cookies, - ); - expectStatus("create tenant", res, 201); - return stringField(res.data, "id", "create tenant"); - }); - - const workbenchId = await hop("workbench creation", async () => { - // Launching a workbench boots its anchor instance in-process, - // which needs the sidecar's dial-in to have completed; retry - // through the transient 500 the same way chat.test.ts does. - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before workbench creation; output:\n${hub.output()}`); - } - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/chat/workbenches`, - { kind: "workbench", name: "Smoke Workbench" }, - cookies, - ); - if (res.status !== 500) { - expectStatus("create workbench", res, 201); - return stringField(res.data, "id", "create workbench"); - } - if (Date.now() > deadline) { - throw new Error( - `workbench never became launchable (hub kept answering 500): ${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(1000); - } - }); - - const messageText = `smoke message ${crypto.randomUUID()}`; - await hop("post a message", async () => { - // Workbench creation no longer waits for the sidecar's dial-in - // (the hub defers the launch), so this first post is the first - // call that needs a routable sidecar — retry through the 500 - // the wake path answers until the dial-in lands. - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, - { parts: [{ kind: "text", text: messageText }] }, - cookies, - ); - if (res.status !== 500) { - expectStatus("post message", res, 201); - stringField(res.data, "id", "post message"); - return; - } - if (Date.now() > deadline) { - throw new Error( - `post message kept answering 500: ${JSON.stringify(res.data)}` + - `\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(1000); - } - }); - - await hop("the message persists with its original content", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, - undefined, - cookies, - ); - expectStatus("list messages", res, 200); - const items = ( - res.data as { - items: { parts: { kind: string; text?: string }[] }[]; - } - ).items; - const found = items.some((item) => - item.parts.some((part) => part.kind === "text" && part.text === messageText), - ); - if (!found) { - throw new Error( - `posted message not found in the workbench's timeline: ${JSON.stringify(items)}`, - ); - } - }); - - await hop("the workbench is listed", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/chat/workbenches`, - undefined, - cookies, - ); - expectStatus("list workbenches", res, 200); - const items = (res.data as { items: { id: string }[] }).items; - const found = items.some((item) => item.id === workbenchId); - if (!found) { - throw new Error( - `created workbench missing from the workbench listing: ${JSON.stringify(items)}`, - ); - } - }); - - await hop("the invited-agent listing has the documented shape", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invitable`, - undefined, - cookies, - ); - expectStatus("list invitable definitions", res, 200); - const body = res.data as { items: { id: string; name: string }[] }; - expect(Array.isArray(body.items)).toBe(true); - // No workflow is deployed on this tenant, so any listed entries - // are platform-provided definitions available by default; the - // contract asserted here is the response shape (an `items` - // array of `{id, name}`), not that the tenant deployed anything. - for (const item of body.items) { - expect(typeof item.id).toBe("string"); - expect(typeof item.name).toBe("string"); - } - }); - } - }, 120_000); -}); diff --git a/scripts/e2e/smoke-onboarding.test.ts b/scripts/e2e/smoke-onboarding.test.ts deleted file mode 100644 index cb15b786b1..0000000000 --- a/scripts/e2e/smoke-onboarding.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Smoke scenario 2/5: first-tenant provisioning. Stock -// Interchange cutover: composition mounts no provisioning hook -// (`POST /api/onboarding/provision` is gone) — signup mints nothing, -// and the first bench comes from an ordinary `POST /api/tenants` with -// a caller-chosen slug, its creator the native owner. This asserts -// that stock flow over the wire: a benchless signup, the mint, and -// the minted root showing up in the creator's own principals. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, -} from "./harness.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "smoke-onboarding: DATABASE_URL is not set; suite skipped. Set " + - "DATABASE_URL (see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` " + - "so this skip can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -describe.skipIf(databaseUrl === undefined)("smoke: first-tenant provisioning", () => { - test("a brand-new signup starts benchless and mints its first tenant via POST /api/tenants", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - await setupDatabase(url); - }); - - const dataDir = await tempDir("e2e-smoke-onboarding-hub-data-"); - const hub = await hop("hub boot", () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir, - // The e2e hub carries no hub-owned seed model credential, and - // the join path never seeds regardless. - }), - ); - track(hub); - const baseUrl = hub.baseUrl; - - const cookies = await hop("sign-up", async () => { - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Onboarding Smoke Tester", - email: `smoke-onboarding-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }); - expectStatus("sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("sign-up returned no session cookie"); - } - return res.cookies; - }); - - const provisioned = await hop( - "signup mints nothing — the account starts benchless", - async () => { - const probe = await api(baseUrl, "GET", "/api/me/principals", undefined, cookies); - expectStatus("principals probe", probe, 200); - const rows = (probe.data as { data: unknown[] }).data; - expect(rows).toEqual([]); - return { benchless: true }; - }, - ); - expect(provisioned.benchless).toBe(true); - - const minted = await hop( - "the first tenant comes from an ordinary POST /api/tenants", - async () => { - const slug = `smoke-onboarding-${crypto.randomUUID().slice(0, 8)}`; - const res = await api( - baseUrl, - "POST", - "/api/tenants", - { name: "Smoke Onboarding", slug }, - cookies, - ); - expectStatus("create tenant", res, 201); - const data = res.data as { id: string; slug?: string }; - const tenantId = stringField(data, "id", "create tenant"); - return { tenantId, tenantSlug: slug }; - }, - ); - - await hop("the provisioned bench is a real tenant membership", async () => { - const res = await api(baseUrl, "GET", "/api/me/principals", undefined, cookies); - expectStatus("list principals", res, 200); - const rows = (res.data as { data: { tenantId: string }[] }).data; - const own = rows.find((row) => row.tenantId === minted.tenantId); - if (own === undefined) { - throw new Error( - `minted tenant ${minted.tenantId} is missing from the caller's own principals: ${JSON.stringify(rows)}`, - ); - } - }); - }, 60_000); -}); diff --git a/scripts/generate-tsconfig-references.test.ts b/scripts/generate-tsconfig-references.test.ts deleted file mode 100644 index 03946bd1f0..0000000000 --- a/scripts/generate-tsconfig-references.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -// Drives the generator through its real command line against a throwaway -// fixture workspace, the same way run-all.test.ts exercises run-all.ts. This -// script is the only thing standing between package.json's real dependency -// graph and every package's `references` array — a silently wrong graph -// would make `tsc --build` skip stale dependents or, worse, refuse the whole -// build over a phantom cycle. -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const GENERATOR = join(import.meta.dir, "generate-tsconfig-references.ts"); - -let workspace = ""; - -async function writePackage( - name: string, - dependencies: Record = {}, - options: { withTest?: boolean; srcFile?: string } = {}, -): Promise { - const dir = join(workspace, "packages", name); - await mkdir(join(dir, "src"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ name: `@fixture/${name}`, dependencies }, null, 2), - ); - await writeFile( - join(dir, "tsconfig.json"), - JSON.stringify({ extends: "../../tsconfig.base.json" }, null, 2), - ); - await writeFile(join(dir, "src", "index.ts"), options.srcFile ?? "export const value = 1;\n"); - if (options.withTest) { - await mkdir(join(dir, "test"), { recursive: true }); - await writeFile(join(dir, "test", "index.test.ts"), "export {};\n"); - } -} - -async function readTsconfig(name: string, file = "tsconfig.json") { - const path = join(workspace, "packages", name, file); - return JSON.parse(await Bun.file(path).text()) as { - extends?: string; - compilerOptions?: Record; - references?: { path: string }[]; - include?: string[]; - }; -} - -async function tsconfigExists(name: string, file: string): Promise { - return Bun.file(join(workspace, "packages", name, file)).exists(); -} - -function run(args: string[]): Promise<{ exitCode: number; stderr: string }> { - return (async () => { - const child = Bun.spawn(["bun", "run", GENERATOR, ...args], { - cwd: workspace, - stdout: "pipe", - stderr: "pipe", - }); - const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]); - return { exitCode, stderr }; - })(); -} - -beforeEach(async () => { - workspace = await mkdtemp(join(tmpdir(), "workbench-tsconfig-refs-")); - for (const root of ["apps", "packages", "tools", "workflows"]) { - await mkdir(join(workspace, root), { recursive: true }); - } - await mkdir(join(workspace, "vendor", "intx"), { recursive: true }); -}); - -afterEach(async () => { - if (workspace !== "") await rm(workspace, { recursive: true, force: true }); -}); - -describe("generate-tsconfig-references", () => { - test("gives a leaf package's tsconfig.src.json composite settings", async () => { - await writePackage("leaf"); - await run([]); - - const config = await readTsconfig("leaf", "tsconfig.src.json"); - expect(config.compilerOptions?.["composite"]).toBe(true); - expect(config.compilerOptions?.["outDir"]).toBe("dist"); - expect(config.references ?? []).toEqual([]); - }); - - test("keeps tsconfig.json as the combined, conventionally-named project", async () => { - // Every tool that discovers a project by convention -- ESLint's - // projectService, an editor, a bare `tsc` in the package directory -- - // looks for a file literally named tsconfig.json. The composite - // src-only project lives at tsconfig.src.json instead, so this stays - // the file such tools find on their own. - await writePackage("leaf"); - await run([]); - - const config = await readTsconfig("leaf"); - expect(config.extends).toBe("./tsconfig.src.json"); - expect(config.compilerOptions?.["composite"]).toBe(false); - }); - - test("gives the combined tsconfig.json its own tsBuildInfoFile", async () => { - // The combined project extends tsconfig.src.json, which sets - // tsBuildInfoFile: "dist/tsconfig.tsbuildinfo" for the composite build. - // Left inherited, the combined project's --noEmit check and the - // composite build both write that same file, and each run invalidates - // the other's incremental state. - await writePackage("leaf"); - await run([]); - - const combined = await readTsconfig("leaf"); - expect(combined.compilerOptions?.["tsBuildInfoFile"]).toBe("tsconfig.tsbuildinfo"); - }); - - test("references a real workspace dependency's tsconfig.src.json", async () => { - await writePackage("leaf"); - await writePackage("consumer", { "@fixture/leaf": "workspace:*" }); - await run([]); - - const config = await readTsconfig("consumer", "tsconfig.src.json"); - expect(config.references).toEqual([{ path: "../leaf/tsconfig.src.json" }]); - }); - - test("does not reference a devDependency", async () => { - await writePackage("leaf"); - const dir = join(workspace, "packages", "consumer"); - await mkdir(join(dir, "src"), { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify( - { - name: "@fixture/consumer", - devDependencies: { "@fixture/leaf": "workspace:*" }, - }, - null, - 2, - ), - ); - await writeFile( - join(dir, "tsconfig.json"), - JSON.stringify({ extends: "../../tsconfig.base.json" }, null, 2), - ); - await writeFile(join(dir, "src", "index.ts"), "export {};\n"); - await run([]); - - const config = await readTsconfig("consumer", "tsconfig.src.json"); - expect(config.references ?? []).toEqual([]); - }); - - test("the combined tsconfig.json references the same deps as tsconfig.src.json", async () => { - await writePackage("leaf"); - await writePackage("consumer", { "@fixture/leaf": "workspace:*" }, { withTest: true }); - await run([]); - - const combined = await readTsconfig("consumer"); - expect(combined.references).toEqual([{ path: "../leaf/tsconfig.src.json" }]); - expect(combined.compilerOptions?.["composite"]).toBe(false); - expect(combined.include).toContain("test"); - }); - - test("gives the combined tsconfig.json an explicit rootDir at the workspace root", async () => { - // Without this, TypeScript infers `rootDir` from `include` alone once - // `outDir` is inherited from the extended src config, and TS6059s on - // any file a test reaches outside "src"/"test" -- which a shared test - // helper living outside every package's own directory always is. - await writePackage("leaf", {}, { withTest: true }); - await run([]); - - const combined = await readTsconfig("leaf"); - expect(combined.compilerOptions?.["rootDir"]).toBe("../.."); - }); - - test("preserves a non-standard include entry across regenerations", async () => { - // packages/e2b-sandbox-sidecar has a `template/` directory that is - // neither `src` nor `test` -- assets read at runtime, not imported -- - // and was silently dropped the first time this generator rebuilt - // `include` from a fixed ["src", "test"] list instead of carrying - // forward whatever the package's own tsconfig.json already declared. - await writePackage("leaf"); - const dir = join(workspace, "packages", "leaf"); - await mkdir(join(dir, "template"), { recursive: true }); - await writeFile( - join(dir, "tsconfig.json"), - JSON.stringify( - { extends: "../../tsconfig.base.json", include: ["src", "template"] }, - null, - 2, - ), - ); - await run([]); - - const combined = await readTsconfig("leaf"); - expect(combined.include).toContain("template"); - - // Idempotent: a second run must not lose it either. - await run([]); - const combinedAgain = await readTsconfig("leaf"); - expect(combinedAgain.include).toContain("template"); - }); - - test("excludes a real dependency cycle from the composite graph entirely", async () => { - await writePackage("cycle-a", { "@fixture/cycle-b": "workspace:*" }); - await writePackage("cycle-b", { "@fixture/cycle-a": "workspace:*" }); - await run([]); - - const a = await readTsconfig("cycle-a"); - expect(a.compilerOptions?.["composite"]).toBeUndefined(); - expect(a.references ?? []).toEqual([]); - expect(await tsconfigExists("cycle-a", "tsconfig.src.json")).toBe(false); - }); - - // A package that is not itself part of a dependency cycle, but depends on - // one that is, must also be excluded from the composite graph. - // Half-excluding just the cycle members leaves their consumers pulling the - // excluded dependency's raw source directly into their own composite - // program, which TypeScript flags as a rootDir violation (TS6059) once it - // walks further into that source's own imports. - test("excludes a transitive (non-cyclic) consumer of a cyclic package", async () => { - await writePackage("cycle-a", { "@fixture/cycle-b": "workspace:*" }); - await writePackage("cycle-b", { "@fixture/cycle-a": "workspace:*" }); - await writePackage("consumer", { "@fixture/cycle-a": "workspace:*" }); - await run([]); - - const consumer = await readTsconfig("consumer"); - expect(consumer.compilerOptions?.["composite"]).toBeUndefined(); - expect(consumer.references ?? []).toEqual([]); - expect(await tsconfigExists("consumer", "tsconfig.src.json")).toBe(false); - }); - - test("excludes a two-hop transitive consumer of a cyclic package", async () => { - await writePackage("cycle-a", { "@fixture/cycle-b": "workspace:*" }); - await writePackage("cycle-b", { "@fixture/cycle-a": "workspace:*" }); - await writePackage("mid", { "@fixture/cycle-a": "workspace:*" }); - await writePackage("outer", { "@fixture/mid": "workspace:*" }); - await run([]); - - const outer = await readTsconfig("outer"); - expect(outer.compilerOptions?.["composite"]).toBeUndefined(); - expect(outer.references ?? []).toEqual([]); - }); - - test("does not exclude an unrelated package outside the cycle's dependents", async () => { - await writePackage("cycle-a", { "@fixture/cycle-b": "workspace:*" }); - await writePackage("cycle-b", { "@fixture/cycle-a": "workspace:*" }); - await writePackage("unrelated"); - await run([]); - - const unrelated = await readTsconfig("unrelated", "tsconfig.src.json"); - expect(unrelated.compilerOptions?.["composite"]).toBe(true); - }); - - test("excludes a package whose src imports a shared root script", async () => { - await writePackage( - "uses-harness", - {}, - { - srcFile: 'import { harness } from "../../../scripts/e2e/harness.ts";\n', - }, - ); - await run([]); - - expect(await tsconfigExists("uses-harness", "tsconfig.src.json")).toBe(false); - const config = await readTsconfig("uses-harness"); - expect(config.compilerOptions?.["composite"]).toBeUndefined(); - }); - - test("--check reports drift without writing", async () => { - await writePackage("leaf"); - const before = await readTsconfig("leaf"); - - const result = await run(["--check"]); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("tsconfig.json"); - expect(await readTsconfig("leaf")).toEqual(before); - }); - - test("--check passes once the tree is in sync", async () => { - await writePackage("leaf"); - await writePackage("consumer", { "@fixture/leaf": "workspace:*" }); - await run([]); - - const result = await run(["--check"]); - - expect(result.exitCode).toBe(0); - }); -}); diff --git a/scripts/generate-tsconfig-references.ts b/scripts/generate-tsconfig-references.ts deleted file mode 100644 index ce6c5170c0..0000000000 --- a/scripts/generate-tsconfig-references.ts +++ /dev/null @@ -1,580 +0,0 @@ -// Derives each package's composite project settings from its own -// package.json `dependencies` (not `devDependencies` — a dev-only edge is -// not a type dependency and is the easiest way to introduce a spurious -// cycle) and a combined `tsconfig.json` that type-checks `src` + `test` -// together. Run with `--check` to fail instead of write, for use in -// `check:tsconfig-references`. -// -// Two tsconfigs per package, not one, and the composite one is NOT named -// `tsconfig.json` -- for two independent reasons: -// -// 1. A composite project's `references` must form a DAG, and test files -// routinely break that: they import `scripts/e2e/harness.ts` (a shared -// test helper, not a workspace package) which itself imports production -// packages like `@corbits/connections` -- so a package upstream of -// `connections` whose *tests* import the harness would otherwise put -// that package's own composite project in a cycle with itself. -// 2. Every tool that discovers a project by convention -- ESLint's -// `projectService`, an editor's language server, a bare `tsc` in the -// package directory -- looks for a file literally named `tsconfig.json` -// and does not know to also check a same-purpose file under a different -// name. Naming the composite src-only project `tsconfig.src.json` -// instead keeps `tsconfig.json` as the combined, conventionally-named -// project every such tool finds on its own; only `tsc --build` and the -// `references` of composite dependents need to know the other name -// exists, and they do so via an explicit path -// (`{"path": "../dep/tsconfig.src.json"}`, not a bare directory). -// -// Splitting src (composite, referenced by dependents, built by -// `tsc --build`) from the combined project (a leaf nothing depends on, so -// it can reference anything without risking a cycle) removes that whole -// class of failure. -import { existsSync } from "node:fs"; -import { relative } from "node:path"; -import { Glob } from "bun"; - -const WORKSPACE_ROOTS = [ - "apps", - "packages", - "tools", - "agents", - "skills", - "workflows", - "vendor/intx", -] as const; - -const SRC_CONFIG_NAME = "tsconfig.src.json"; - -type PackageManifest = { - readonly name: string; - readonly dir: string; - readonly workspaceDeps: readonly string[]; -}; - -async function readManifests(): Promise { - const manifests: PackageManifest[] = []; - for (const root of WORKSPACE_ROOTS) { - const glob = new Glob(`${root}/*/package.json`); - for await (const manifestPath of glob.scan(".")) { - const raw = (await Bun.file(manifestPath).json()) as { - name?: string; - dependencies?: Record; - }; - const dir = manifestPath.slice(0, -"/package.json".length); - manifests.push({ - name: raw.name ?? dir, - dir, - workspaceDeps: Object.entries(raw.dependencies ?? {}) - .filter(([, range]) => range.startsWith("workspace:")) - .map(([dep]) => dep), - }); - } - } - return manifests; -} - -type Tsconfig = { - extends?: string; - compilerOptions?: Record; - include?: string[]; - references?: { path: string }[]; - [key: string]: unknown; -}; - -const BUILD_COMPILER_OPTIONS = { - composite: true, - emitDeclarationOnly: true, - outDir: "dist", - tsBuildInfoFile: "dist/tsconfig.tsbuildinfo", -}; - -const COMBINED_COMPILER_OPTIONS = { - composite: false, - noEmit: true, - disableSourceOfProjectReferenceRedirect: true, - // `noEmit` alone does not turn off `rootDir` enforcement -- TypeScript - // still computes it for declaration output whenever `declaration` is on, - // which every project inherits from tsconfig.base.json. This project - // reaches test helpers and, through them, whatever those helpers import, - // all outside its own package directory; declaration output was never - // wanted here, so switch off everything that makes TypeScript care where - // those files live. - declaration: false, - declarationMap: false, - emitDeclarationOnly: false, - // Left unset, `extends` inherits tsBuildInfoFile: "dist/tsconfig.tsbuildinfo" - // from tsconfig.src.json, so the combined project's --noEmit check and the - // composite build write the same file and each invalidates the other's - // incremental state. Giving the combined project its own path (next to - // this tsconfig.json, since there's no outDir here to nest it under) - // keeps the two builds independent. - tsBuildInfoFile: "tsconfig.tsbuildinfo", -}; - -/** - * TypeScript project references must form a DAG: a single cycle anywhere - * makes `tsc --build` refuse the whole graph. The workspace's real `src` - * `dependencies` graph has one strongly-connected component (a handful of - * packages depend on each other, directly or transitively), so edges that - * stay inside the same component are dropped from `references` — those - * packages keep resolving each other's types via plain source imports, - * same as before this change, just without the composite build's - * skip-if-up-to-date benefit between themselves. - */ -function stronglyConnectedComponents( - manifests: readonly PackageManifest[], -): ReadonlyMap { - const byName = new Map(manifests.map((m) => [m.name, m])); - let counter = 0; - const indices = new Map(); - const lowlink = new Map(); - const onStack = new Set(); - const stack: string[] = []; - const componentOf = new Map(); - let nextComponentId = 0; - - function strongconnect(v: string): void { - indices.set(v, counter); - lowlink.set(v, counter); - counter += 1; - stack.push(v); - onStack.add(v); - - for (const w of byName.get(v)?.workspaceDeps ?? []) { - if (!byName.has(w)) continue; - if (!indices.has(w)) { - strongconnect(w); - lowlink.set(v, Math.min(lowlink.get(v) as number, lowlink.get(w) as number)); - } else if (onStack.has(w)) { - lowlink.set(v, Math.min(lowlink.get(v) as number, indices.get(w) as number)); - } - } - - if (lowlink.get(v) === indices.get(v)) { - const id = nextComponentId; - nextComponentId += 1; - let member: string | undefined; - do { - member = stack.pop(); - if (member === undefined) break; - onStack.delete(member); - componentOf.set(member, id); - } while (member !== v); - } - } - - for (const manifest of manifests) { - if (!indices.has(manifest.name)) strongconnect(manifest.name); - } - return componentOf; -} - -/** - * Packages in a multi-member strongly-connected component don't just drop - * the edges *within* that component (handled by `stronglyConnectedComponents` - * above) -- in practice the file-level import cycle that remains once one - * edge is dropped from a composite `--build` graph makes `tsc --build` - * pathologically slow (observed: single packages that never finished within - * five minutes, well past the 600s gate cap). These packages are excluded - * from the composite graph entirely rather than half-participating in it; - * see `cyclicNames` in `main`. - */ -function srcReferencePathsFor( - manifest: PackageManifest, - byName: ReadonlyMap, - withTsconfig: ReadonlySet, - cyclicNames: ReadonlySet, -): string[] { - const paths: string[] = []; - for (const dep of manifest.workspaceDeps) { - const target = byName.get(dep); - if (target === undefined || !withTsconfig.has(target.name)) continue; - if (cyclicNames.has(target.name)) continue; - const rel = relative(manifest.dir, target.dir) || "."; - const relDir = rel.startsWith(".") ? rel : `./${rel}`; - paths.push(`${relDir}/${SRC_CONFIG_NAME}`); - } - return paths.sort(); -} - -/** - * `scripts/e2e/harness.ts`, `scripts/db-setup.ts` and `test/isolation/setup.ts` - * are shared test/setup helpers that many packages import by relative path - * -- they are not workspace packages (no `package.json` dependency records - * the edge), are not composite, and pull in production packages themselves - * (`harness.ts` imports `@corbits/connections`), so they can never - * legally appear in a `references` array. A package's `test/` directory - * importing one is fine -- the combined non-composite project resolves it - * via plain source, same as any other undeclared import. A package's `src` - * importing one is not: a composite project cannot cross its rootDir via an - * undeclared relative import, so such a package is excluded from the - * composite graph entirely (see `excludedNames` in `main`), the same - * treatment as a real dependency cycle. - */ -const ROOT_SCRIPT_MARKERS = [ - /(?:from|import\()\s*["'][^"'\n]*scripts\/e2e[^"'\n]*["']/, - /(?:from|import\()\s*["'][^"'\n]*scripts\/db-setup[^"'\n]*["']/, - /(?:from|import\()\s*["'][^"'\n]*test\/isolation[^"'\n]*["']/, -] as const; - -// Matched as an import specifier (quoted), not a substring anywhere in the -// file -- a comment mentioning "scripts/db-setup.ts" is not an import of it. -async function importsRootScript(dir: string): Promise { - const glob = new Glob(`${dir}/**/*.ts`); - for await (const path of glob.scan(".")) { - if (path.includes("/dist/") || path.includes("/node_modules/")) continue; - const text = await Bun.file(path).text(); - if (ROOT_SCRIPT_MARKERS.some((pattern) => pattern.test(text))) return true; - } - return false; -} - -async function loadTsconfig(path: string): Promise { - const file = Bun.file(path); - if (!(await file.exists())) return undefined; - return (await file.json()) as Tsconfig; -} - -// Colocated unit tests (`src/**/*.test.ts`, see AGENTS.md) commonly reach -// outside `src` -- into the package's own `package.json`, or a `test/` -// fixtures module -- which the composite src project cannot allow across -// its rootDir. They are excluded here and picked up by the combined -// project's `include` instead, alongside a dedicated `test/` directory -// when present. -function withSrcFields(dir: string, config: Tsconfig, referencePaths: readonly string[]): Tsconfig { - const { - compilerOptions, - references: _oldReferences, - include, - exclude: _oldExclude, - extends: _oldExtends, - ...rest - } = config; - return { - // Not hand-copied from the seed: `vendor/intx/*` sits one directory - // deeper than `apps/*`/`packages/*`/`tools/*`/`workflows/*`, so a fixed - // "../../" string resolves to a path that doesn't exist for it -- an - // unresolvable `extends` silently drops everything the base config - // sets, `skipLibCheck` included, and node_modules' own .d.ts files - // start failing the build. - extends: `${relative(dir, ".")}/tsconfig.base.json`, - ...rest, - include: [ - ...(include ?? ["src"]).filter( - (entry) => entry !== "test" && entry !== "package.json" && entry !== "src/**/*.json", - ), - "package.json", - "src/**/*.json", - ], - exclude: ["src/**/*.test.ts", "src/**/*.test.tsx"], - compilerOptions: { ...compilerOptions, ...BUILD_COMPILER_OPTIONS }, - ...(referencePaths.length > 0 ? { references: referencePaths.map((path) => ({ path })) } : {}), - }; -} - -// A cyclic package keeps the pre-change shape: no composite settings, no -// references, `src` + `test` checked together as one non-build project. It -// is excluded from `tsc --build` and checked separately; see -// scripts/typecheck.ts. -function withLegacyFields(config: Tsconfig): Tsconfig { - const { - compilerOptions, - references: _oldReferences, - include, - exclude: _oldExclude, - ...rest - } = config; - const { - composite: _c, - emitDeclarationOnly: _e, - outDir: _o, - tsBuildInfoFile: _t, - ...restOptions - } = compilerOptions ?? {}; - const cleanedInclude = (include ?? ["src"]).filter( - (entry) => entry !== "package.json" && entry !== "src/**/*.json", - ); - return { - ...rest, - include: cleanedInclude.includes("test") ? cleanedInclude : [...cleanedInclude, "test"], - compilerOptions: { ...restOptions, noEmit: true }, - }; -} - -function legacyFieldsMatch(current: Tsconfig, next: Tsconfig): boolean { - const currentOptions = current.compilerOptions ?? {}; - for (const key of Object.keys(BUILD_COMPILER_OPTIONS)) { - if (key in currentOptions) return false; - } - if (currentOptions.noEmit !== true) return false; - if (current.references !== undefined) return false; - return listsMatch(current.include, next.include); -} - -// The combined project: everyone's `tsconfig.json` by name, so every tool -// that discovers a project by convention (ESLint's `projectService`, an -// editor, a bare `tsc` invocation) finds it without being told the -// composite project exists at all. -function combinedConfigFor( - dir: string, - referencePaths: readonly string[], - hasTestDir: boolean, - extraInclude: readonly string[], -): Tsconfig { - const baseInclude = hasTestDir ? ["src", "test"] : ["src"]; - return { - extends: `./${SRC_CONFIG_NAME}`, - compilerOptions: { - ...COMBINED_COMPILER_OPTIONS, - // `extends` still carries `outDir: "dist"` down from the composite - // src config. With an `outDir` but no explicit `rootDir`, TypeScript - // infers `rootDir` from `include` alone -- "src" and "test" -- not - // from the full set of files the program actually reaches. A test - // file importing a shared test helper (which itself imports whatever - // production code that helper touches) reaches files nowhere near - // "src"/"test", and TS6059s on every one of them despite `noEmit`. - // An explicit `rootDir` at the repository root is a real ancestor of - // every file the workspace can ever reach, so the check always - // passes; nothing is emitted here regardless. - rootDir: relative(dir, ".") || ".", - }, - // A package can have content outside src/test that nothing else here - // knows about (packages/e2b-sandbox-sidecar's `template/`, a sandbox - // asset tree read at runtime, not imported) -- carried forward from - // whatever the package's own tsconfig.json already declared rather - // than silently dropped by rebuilding `include` from a fixed list. - include: [...new Set([...baseInclude, ...extraInclude])], - exclude: [], - ...(referencePaths.length > 0 ? { references: referencePaths.map((path) => ({ path })) } : {}), - }; -} - -// Formatting is prettier's job (enforced separately by `bun run lint`), so -// drift is judged on the meaningful fields only, not on whitespace. -function srcFieldsMatch(current: Tsconfig, next: Tsconfig): boolean { - if (current.extends !== next.extends) return false; - const currentOptions = current.compilerOptions ?? {}; - const nextOptions = next.compilerOptions ?? {}; - for (const key of Object.keys(BUILD_COMPILER_OPTIONS)) { - if (currentOptions[key] !== nextOptions[key]) return false; - } - if (!referencesMatch(current.references, next.references)) return false; - if (!listsMatch(current.include, next.include)) return false; - return listsMatch(current.exclude as string[] | undefined, next.exclude as string[] | undefined); -} - -function listsMatch( - current: readonly string[] | undefined, - next: readonly string[] | undefined, -): boolean { - const a = [...(current ?? [])].sort(); - const b = [...(next ?? [])].sort(); - return a.length === b.length && a.every((v, i) => v === b[i]); -} - -function referencesMatch(current: Tsconfig["references"], next: Tsconfig["references"]): boolean { - const currentPaths = (current ?? []).map((r) => r.path).sort(); - const nextPaths = (next ?? []).map((r) => r.path).sort(); - return ( - currentPaths.length === nextPaths.length && - currentPaths.every((path, i) => path === nextPaths[i]) - ); -} - -function combinedFieldsMatch(current: Tsconfig | undefined, next: Tsconfig): boolean { - if (current === undefined) return false; - const currentOptions = current.compilerOptions ?? {}; - const nextOptions = next.compilerOptions ?? {}; - for (const key of [...Object.keys(COMBINED_COMPILER_OPTIONS), "rootDir"]) { - if (currentOptions[key] !== nextOptions[key]) return false; - } - if (current.extends !== next.extends) return false; - return ( - referencesMatch(current.references, next.references) && - listsMatch(current.include, next.include) - ); -} - -async function main(): Promise { - const checkOnly = process.argv.includes("--check"); - const manifests = await readManifests(); - const byName = new Map(manifests.map((m) => [m.name, m])); - const componentOf = stronglyConnectedComponents(manifests); - const componentSizes = new Map(); - for (const id of componentOf.values()) { - componentSizes.set(id, (componentSizes.get(id) ?? 0) + 1); - } - const cyclicNames = new Set( - [...componentOf.entries()] - .filter(([, id]) => (componentSizes.get(id) ?? 0) > 1) - .map(([name]) => name), - ); - const excludedNames = new Set(cyclicNames); - for (const manifest of manifests) { - if (await importsRootScript(`${manifest.dir}/src`)) { - excludedNames.add(manifest.name); - } - } - - // Excluding a package from the composite graph without also excluding - // every package that depends on it is not a smaller version of the same - // fix -- it is broken. A composite project cannot put a non-composite - // dependency in `references` (no declarations to consume), so it falls - // back to resolving that dependency's *source* directly. TypeScript then - // keeps walking that source's own imports -- test helpers included -- - // with no project boundary to stop at, and flags every file it reaches - // outside the dependent's rootDir. The exclusion has to propagate to - // every transitive consumer, not just the packages in the cycle itself. - let excludedGrew = true; - while (excludedGrew) { - excludedGrew = false; - for (const manifest of manifests) { - if (excludedNames.has(manifest.name)) continue; - if (manifest.workspaceDeps.some((dep) => excludedNames.has(dep))) { - excludedNames.add(manifest.name); - excludedGrew = true; - } - } - } - - // The combined `tsconfig.json` is the thing every package has always - // had; it's the seed for a composite package's `tsconfig.src.json` - // (carrying forward any package-specific compilerOptions, like - // `types: ["bun"]`) and, for a legacy package, the file itself. - const existingCombined = new Map(); - for (const manifest of manifests) { - const config = await loadTsconfig(`${manifest.dir}/tsconfig.json`); - if (config !== undefined) existingCombined.set(manifest.name, config); - } - const withTsconfig = new Set(existingCombined.keys()); - - const drifted: string[] = []; - const written: string[] = []; - const removed: string[] = []; - for (const manifest of manifests) { - const currentCombined = existingCombined.get(manifest.name); - if (currentCombined === undefined) continue; - - const srcConfigPath = `${manifest.dir}/${SRC_CONFIG_NAME}`; - - if (excludedNames.has(manifest.name)) { - const nextLegacy = withLegacyFields(currentCombined); - if (!legacyFieldsMatch(currentCombined, nextLegacy)) { - if (checkOnly) { - drifted.push(`${manifest.dir}/tsconfig.json`); - } else { - const path = `${manifest.dir}/tsconfig.json`; - await Bun.write(path, `${JSON.stringify(nextLegacy, null, 2)}\n`); - written.push(path); - } - } - if (existsSync(srcConfigPath)) { - if (checkOnly) drifted.push(srcConfigPath); - else { - // Removed, not written -- prettier has nothing left to format. - await Bun.file(srcConfigPath).delete(); - removed.push(srcConfigPath); - } - } - continue; - } - - const srcReferences = srcReferencePathsFor(manifest, byName, withTsconfig, excludedNames); - // The current tsconfig.src.json (if this isn't the first run) carries - // any package-specific compilerOptions already merged in; fall back to - // the combined file only the very first time a package joins the - // composite graph. - const srcSeed = (await loadTsconfig(srcConfigPath)) ?? currentCombined; - const nextSrc = withSrcFields(manifest.dir, srcSeed, srcReferences); - const currentSrc = await loadTsconfig(srcConfigPath); - if (currentSrc === undefined || !srcFieldsMatch(currentSrc, nextSrc)) { - if (checkOnly) { - drifted.push(srcConfigPath); - } else { - await Bun.write(srcConfigPath, `${JSON.stringify(nextSrc, null, 2)}\n`); - written.push(srcConfigPath); - } - } - - const hasTestDir = existsSync(`${manifest.dir}/test`); - const extraInclude = (currentCombined.include ?? []).filter( - (entry) => entry !== "src" && entry !== "test", - ); - const nextCombined = combinedConfigFor(manifest.dir, srcReferences, hasTestDir, extraInclude); - if (!combinedFieldsMatch(currentCombined, nextCombined)) { - if (checkOnly) { - drifted.push(`${manifest.dir}/tsconfig.json`); - } else { - const path = `${manifest.dir}/tsconfig.json`; - await Bun.write(path, `${JSON.stringify(nextCombined, null, 2)}\n`); - written.push(path); - } - } - - // `tsconfig.test.json` predates the rename above -- the combined - // project it held now lives at `tsconfig.json` directly. - const stalePath = `${manifest.dir}/tsconfig.test.json`; - if (existsSync(stalePath)) { - if (checkOnly) { - drifted.push(stalePath); - } else { - await Bun.file(stalePath).delete(); - removed.push(stalePath); - } - } - } - - // `tsc --build` given many unrelated root projects as separate CLI - // arguments shares source-file/diagnostic state across them in a way that - // misattributes `rootDir` violations to the wrong project entirely - // (observed: a clean project reported errors that belonged to a project - // built earlier in the same invocation). Handing it one root -- a - // solution file whose only content is `references` to every composite - // project's tsconfig.src.json -- avoids that: `tsc --build` still walks - // the full transitive graph and skips whatever is already up to date, - // but the diagnostic state is no longer split across sibling root - // arguments. - const solutionReferences = manifests - .filter((manifest) => existingCombined.has(manifest.name) && !excludedNames.has(manifest.name)) - .map((manifest) => manifest.dir) - .sort() - .map((dir) => ({ path: `./${dir}/${SRC_CONFIG_NAME}` })); - const solutionPath = "tsconfig.build.json"; - const nextSolution: Tsconfig = { - files: [], - references: solutionReferences, - }; - const currentSolution = await loadTsconfig(solutionPath); - if ( - currentSolution === undefined || - !referencesMatch(currentSolution.references, nextSolution.references) - ) { - if (checkOnly) { - drifted.push(solutionPath); - } else { - await Bun.write(solutionPath, `${JSON.stringify(nextSolution, null, 2)}\n`); - written.push(solutionPath); - } - } - - if (checkOnly && drifted.length > 0) { - console.error( - `tsconfig references are out of sync with package.json dependencies in ${drifted.length} file(s):\n${drifted.map((d) => ` ${d}`).join("\n")}\nRun: bun run scripts/generate-tsconfig-references.ts`, - ); - process.exit(1); - } - - if (!checkOnly && written.length > 0) { - const format = Bun.spawn(["bunx", "prettier", "--write", ...written], { - stdout: "inherit", - stderr: "inherit", - }); - await format.exited; - } - if (!checkOnly && removed.length > 0) { - console.error(`removed ${removed.length} stale file(s):`); - for (const path of removed) console.error(` ${path}`); - } -} - -await main(); diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push deleted file mode 100755 index 6b3694c947..0000000000 --- a/scripts/git-hooks/pre-push +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Local stand-in for the cheap CI jobs. Heavy suites stay on GitHub. -set -eu - -if [ -n "${SKIP_WORKBENCH_HOOKS:-}" ] || [ -n "${CI:-}" ]; then - exit 0 -fi - -bun run lint -bun run typecheck -bun run test diff --git a/scripts/hooks-install.test.ts b/scripts/hooks-install.test.ts deleted file mode 100644 index 0b96eda265..0000000000 --- a/scripts/hooks-install.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Cheap-gate hook: skip env must not spawn lint, and the hook must not -// invoke the heavy suites that stay on GitHub CI. -import { describe, expect, test } from "bun:test"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -const HOOK = join(import.meta.dir, "git-hooks", "pre-push"); -const INSTALLER = join(import.meta.dir, "hooks-install.ts"); - -const HEAVY_SUITE_MARKERS = ["test:e2e", "walking-skeleton"] as const; - -function ambientEnv(overrides: Record = {}): Record { - const { - CI: _ci, - GITHUB_ACTIONS: _gha, - SKIP_WORKBENCH_HOOKS: _skip, - GIT_DIR: _gitDir, - GIT_WORK_TREE: _gitWorkTree, - GIT_CONFIG_GLOBAL: _global, - GIT_CONFIG_SYSTEM: _system, - ...rest - } = process.env; - const env: Record = {}; - for (const [key, value] of Object.entries(rest)) { - if (value !== undefined) env[key] = value; - } - return { ...env, GIT_CONFIG_NOSYSTEM: "1", ...overrides }; -} - -async function spawnCapture( - cmd: string[], - opts: { cwd?: string; env?: Record }, -): Promise<{ code: number; stdout: string; stderr: string }> { - const child = Bun.spawn(cmd, { - ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), - env: opts.env ?? ambientEnv(), - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, code] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - return { code, stdout, stderr }; -} - -async function writeBunStub(dir: string, logPath: string): Promise { - const bunPath = join(dir, "bun"); - await writeFile(bunPath, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${logPath}"\n`, "utf-8"); - await chmod(bunPath, 0o755); -} - -describe("pre-push hook", () => { - test("exits 0 immediately when SKIP_WORKBENCH_HOOKS is set and does not spawn lint", async () => { - const work = await mkdtemp(join(tmpdir(), "pre-push-skip-")); - try { - await chmod(HOOK, 0o755); - const logPath = join(work, "bun.log"); - await writeBunStub(work, logPath); - const result = await spawnCapture([HOOK], { - env: ambientEnv({ - PATH: `${work}:${process.env.PATH ?? ""}`, - SKIP_WORKBENCH_HOOKS: "1", - }), - }); - expect(result.code).toBe(0); - expect(await Bun.file(logPath).exists()).toBe(false); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); - - test("exits 0 immediately when CI is set and does not spawn lint", async () => { - const work = await mkdtemp(join(tmpdir(), "pre-push-ci-")); - try { - await chmod(HOOK, 0o755); - const logPath = join(work, "bun.log"); - await writeBunStub(work, logPath); - const result = await spawnCapture([HOOK], { - env: ambientEnv({ - PATH: `${work}:${process.env.PATH ?? ""}`, - CI: "true", - }), - }); - expect(result.code).toBe(0); - expect(await Bun.file(logPath).exists()).toBe(false); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); - - test("runs lint, typecheck, and unit tests through bun, not e2e", async () => { - const work = await mkdtemp(join(tmpdir(), "pre-push-gates-")); - try { - await chmod(HOOK, 0o755); - const repo = join(work, "repo"); - await spawnCapture(["git", "init", "--initial-branch=main", repo], {}); - const logPath = join(work, "bun.log"); - await writeBunStub(work, logPath); - const result = await spawnCapture([HOOK], { - cwd: repo, - env: ambientEnv({ - PATH: `${work}:${process.env.PATH ?? ""}`, - }), - }); - expect(result.code).toBe(0); - const log = await readFile(logPath, "utf-8"); - expect(log).toContain("run lint"); - expect(log).toContain("run typecheck"); - expect(log).toContain("run test"); - expect(log).not.toContain("test:e2e"); - expect(log).not.toContain("walking-skeleton"); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); - - test("does not mention test:e2e or walking-skeleton as a command it runs", async () => { - const source = await readFile(HOOK, "utf-8"); - for (const marker of HEAVY_SUITE_MARKERS) { - expect(source).not.toContain(marker); - } - expect(source).toContain("bun run lint"); - expect(source).toContain("bun run typecheck"); - expect(source).toContain("bun run test"); - }); -}); - -describe("hooks:install", () => { - test("does not mention test:e2e or walking-skeleton as a command it runs", async () => { - const source = await readFile(INSTALLER, "utf-8"); - for (const marker of HEAVY_SUITE_MARKERS) { - expect(source).not.toContain(marker); - } - }); - - test("sets repo-local core.hooksPath when CI is unset", async () => { - const work = await mkdtemp(join(tmpdir(), "hooks-install-")); - try { - await spawnCapture(["git", "init", "--initial-branch=main", work], {}); - const result = await spawnCapture(["bun", "run", INSTALLER], { - cwd: work, - env: ambientEnv(), - }); - expect(result.code).toBe(0); - const hooksPath = await spawnCapture( - ["git", "config", "--local", "--get", "core.hooksPath"], - { cwd: work, env: ambientEnv() }, - ); - expect(hooksPath.stdout.trim()).toBe("scripts/git-hooks"); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); - - test("is a no-op in CI and does not set core.hooksPath", async () => { - const work = await mkdtemp(join(tmpdir(), "hooks-install-ci-")); - try { - await spawnCapture(["git", "init", "--initial-branch=main", work], {}); - const result = await spawnCapture(["bun", "run", INSTALLER], { - cwd: work, - env: ambientEnv({ CI: "true" }), - }); - expect(result.code).toBe(0); - const hooksPath = await spawnCapture( - ["git", "config", "--local", "--get", "core.hooksPath"], - { cwd: work, env: ambientEnv() }, - ); - expect(hooksPath.code).not.toBe(0); - expect(hooksPath.stdout.trim()).toBe(""); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); - - test("is a no-op when GITHUB_ACTIONS is set", async () => { - const work = await mkdtemp(join(tmpdir(), "hooks-install-gha-")); - try { - await spawnCapture(["git", "init", "--initial-branch=main", work], {}); - const result = await spawnCapture(["bun", "run", INSTALLER], { - cwd: work, - env: ambientEnv({ GITHUB_ACTIONS: "true" }), - }); - expect(result.code).toBe(0); - const hooksPath = await spawnCapture( - ["git", "config", "--local", "--get", "core.hooksPath"], - { cwd: work, env: ambientEnv() }, - ); - expect(hooksPath.stdout.trim()).toBe(""); - } finally { - await rm(work, { recursive: true, force: true }); - } - }); -}); diff --git a/scripts/hooks-install.ts b/scripts/hooks-install.ts deleted file mode 100644 index 3c9092ab38..0000000000 --- a/scripts/hooks-install.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Sets this checkout's local core.hooksPath to scripts/git-hooks so -// `git push` runs lint, typecheck, and unit tests. No-op in CI so -// `bun install` does not rewrite GitHub Actions git config. - -const HOOKS_PATH = "scripts/git-hooks"; - -function flagSet(value: string | undefined): boolean { - return value !== undefined && value !== ""; -} - -function main(): void { - if (flagSet(process.env.CI) || flagSet(process.env.GITHUB_ACTIONS)) { - return; - } - if (Bun.which("git") === null) { - return; - } - - const toplevel = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { - stdout: "pipe", - stderr: "pipe", - }); - if (toplevel.exitCode !== 0) { - return; - } - - const root = toplevel.stdout.toString().trim(); - const result = Bun.spawnSync(["git", "config", "--local", "core.hooksPath", HOOKS_PATH], { - cwd: root, - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode !== 0) { - const detail = `${result.stdout.toString()}${result.stderr.toString()}`.trim(); - console.error( - detail.length > 0 - ? `hooks:install failed: ${detail}` - : "hooks:install failed: git config core.hooksPath", - ); - process.exit(result.exitCode ?? 1); - } -} - -if (import.meta.main) { - main(); -} diff --git a/scripts/lib/publishable-packages.ts b/scripts/lib/publishable-packages.ts deleted file mode 100644 index a9a4a44062..0000000000 --- a/scripts/lib/publishable-packages.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Shared plumbing for deploy-compatibility auditing: finds every -// workspace package and splits it into publishable (not `private`) vs. -// still-private, the same way scripts/checks/publishable-workflows.ts -// scopes its audit. Computed from package.json files on disk — never -// hand-typed — so a package flipping `private` never silently drifts out -// of sync with what the check expects. -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { Glob } from "bun"; - -export const WORKSPACE_GLOBS = [ - "apps/*/package.json", - "packages/*/package.json", - "templates/package.json", - "tools/*/package.json", - "workflows/*/package.json", -]; - -export interface WorkspacePackage { - readonly dir: string; - readonly name: string; - readonly version: string; - readonly private: boolean; - /** Names from `dependencies` + `peerDependencies`, workspace or not. */ - readonly deps: readonly string[]; -} - -export async function listWorkspacePackages(root: string): Promise { - const packages: WorkspacePackage[] = []; - for (const pattern of WORKSPACE_GLOBS) { - const glob = new Glob(pattern); - for await (const manifestPath of glob.scan(root)) { - const dir = path.dirname(manifestPath); - const raw = JSON.parse(readFileSync(path.join(root, manifestPath), "utf8")); - if (typeof raw.name !== "string") continue; - const deps = { - ...(raw.dependencies ?? {}), - ...(raw.peerDependencies ?? {}), - }; - packages.push({ - dir, - name: raw.name, - version: typeof raw.version === "string" ? raw.version : "0.0.0", - private: raw.private === true, - deps: Object.keys(deps), - }); - } - } - return packages.sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * A package is unpublishable when it's `private` itself, or when it - * depends (directly or transitively) on a package that is — a published - * tarball of it could never install that dependency. Computed to a - * fixpoint so the effect propagates, e.g. A depends on B depends on - * private C: both A and B are unpublishable. - */ -export async function listUnpublishablePackageNames(root: string): Promise { - const packages = await listWorkspacePackages(root); - const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); - const unpublishable = new Set(packages.filter((pkg) => pkg.private).map((pkg) => pkg.name)); - let changed = true; - while (changed) { - changed = false; - for (const pkg of packages) { - if (unpublishable.has(pkg.name)) continue; - for (const dep of pkg.deps) { - if (!byName.has(dep)) continue; - if (unpublishable.has(dep)) { - unpublishable.add(pkg.name); - changed = true; - break; - } - } - } - } - return [...unpublishable].sort(); -} - -export async function listPublishablePackages(root: string): Promise { - const unpublishable = new Set(await listUnpublishablePackageNames(root)); - return (await listWorkspacePackages(root)).filter((pkg) => !unpublishable.has(pkg.name)); -} diff --git a/scripts/publish-tools.test.ts b/scripts/publish-tools.test.ts deleted file mode 100644 index 95c21ba997..0000000000 --- a/scripts/publish-tools.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -// Unit gates for `bun run publish-tools`: a stub hub API proves the -// command signs in (never signs up), resolves the target tenant from -// `--tenant` or the admin's sole membership, and invokes the existing -// publisher with the signed-in cookies. The publisher itself is proven -// by `packages/tool-registry-publish`'s suite and the local-rip e2e hop. -import { describe, expect, test } from "bun:test"; -import { runPublishTools, type PublishToolsArgs } from "./publish-tools.ts"; - -type ApiStub = ( - method: string, - path: string, -) => Promise<{ status: number; data: unknown; cookies: string[] }>; - -function principalsRow(tenant: { id: string; slug: string; name: string }): unknown { - return { - principalId: `principal_${tenant.id}`, - tenantId: tenant.id, - tenantName: tenant.name, - tenantSlug: tenant.slug, - kind: "user", - status: "active", - roles: [{ id: "role_owner", name: "owner" }], - }; -} - -function stubApi(principals: unknown[]): { - api: ApiStub; - calls: { method: string; path: string }[]; -} { - const calls: { method: string; path: string }[] = []; - const api: ApiStub = (method, path) => { - calls.push({ method, path }); - if (path === "/api/auth/sign-in/email") { - return Promise.resolve({ - status: 200, - data: { user: { id: "user_admin" } }, - cookies: ["better-auth.session_token=stub"], - }); - } - if (path === "/api/me/principals") { - return Promise.resolve({ - status: 200, - data: { data: principals, nextCursor: null }, - cookies: [], - }); - } - if (path.includes("/assets?")) { - return Promise.resolve({ status: 200, data: [], cookies: [] }); - } - if (method === "POST" && path === "/api/tenants/tenant_genesis/assets") { - return Promise.resolve({ - status: 201, - data: { - id: "asset_registry", - kind: "package-registry", - name: "corbits-tools", - displayName: null, - tenantId: "tenant_genesis", - creatorPrincipalId: null, - createdAt: new Date(0).toISOString(), - updatedAt: new Date(0).toISOString(), - }, - cookies: [], - }); - } - if (path.includes("/tarballs/")) { - return Promise.resolve({ - status: 200, - data: { commit: "sha_stub", integrity: "sha512-stub" }, - cookies: [], - }); - } - return Promise.resolve({ status: 404, data: null, cookies: [] }); - }; - return { api, calls }; -} - -function args( - api: ApiStub, - tenant?: string, - calls?: { method: string; path: string }[], -): PublishToolsArgs { - return { - hubUrl: "http://localhost:3000", - email: "admin@example.com", - password: "password123", - ...(tenant !== undefined ? { tenant } : {}), - log: () => undefined, - checkFreshness: () => Promise.resolve(), - packageDirs: ["/stub/package"], - fetchImpl: (input, init) => { - calls?.push({ - method: init.method ?? "GET", - path: new URL(input).pathname, - }); - return Promise.resolve( - new Response(JSON.stringify({ commit: "sha_stub", integrity: "sha512-stub" }), { - status: 200, - }), - ); - }, - pack: () => - Promise.resolve({ - name: "@corbits/capability-tools", - version: "0.0.1", - filename: "corbits-capability-tools-0.0.1.tgz", - bytes: new TextEncoder().encode("tarball-bytes"), - }), - api: api as never, - }; -} - -describe("runPublishTools", () => { - test("signs in, resolves the sole membership, and publishes onto it", async () => { - const { api, calls } = stubApi([ - principalsRow({ - id: "tenant_genesis", - slug: "genesis-root", - name: "Genesis Root", - }), - ]); - const puts: { method: string; path: string }[] = []; - const result = await runPublishTools(args(api, undefined, puts)); - expect(result.success).toBe(true); - expect( - calls.some((call) => call.method === "POST" && call.path === "/api/auth/sign-in/email"), - ).toBe(true); - expect( - calls.some( - (call) => call.method === "POST" && call.path === "/api/tenants/tenant_genesis/assets", - ), - ).toBe(true); - expect( - puts.some( - (call) => - call.method === "PUT" && - call.path === - "/api/tenants/tenant_genesis/assets/asset_registry/tarballs/corbits-capability-tools-0.0.1.tgz", - ), - ).toBe(true); - }); - - test("honors --tenant by slug", async () => { - const { api, calls } = stubApi([ - principalsRow({ - id: "tenant_genesis", - slug: "genesis-root", - name: "Genesis Root", - }), - ]); - await runPublishTools(args(api, "genesis-root")); - expect(calls.some((call) => call.path.includes("tenant_genesis"))).toBe(true); - }); - - test("fails when --tenant matches no membership", async () => { - const { api } = stubApi([ - principalsRow({ - id: "tenant_genesis", - slug: "genesis-root", - name: "Genesis Root", - }), - ]); - await expect(runPublishTools(args(api, "nope"))).rejects.toThrow(/no tenant matching "nope"/); - }); - - test("fails when the admin belongs to several tenants and no --tenant is given", async () => { - const { api } = stubApi([ - principalsRow({ - id: "tenant_genesis", - slug: "genesis-root", - name: "Genesis Root", - }), - principalsRow({ id: "tenant_other", slug: "other", name: "Other" }), - ]); - await expect(runPublishTools(args(api))).rejects.toThrow(/--tenant is required/); - }); -}); diff --git a/scripts/publish-tools.ts b/scripts/publish-tools.ts index 831daff08b..b0616339d6 100644 --- a/scripts/publish-tools.ts +++ b/scripts/publish-tools.ts @@ -10,7 +10,7 @@ // (defaults to the admin's only membership when exactly one). import { type } from "arktype"; import { PrincipalSummary, paginatedSchema } from "@intx/types"; -// Relative imports, the same convention scripts/e2e uses: the root +// Relative imports, the same convention e2e uses: the root // package.json does not depend on these workspace packages, only the // hub-facing products do. import { diff --git a/scripts/repro/WALKPARTS.md b/scripts/repro/WALKPARTS.md deleted file mode 100644 index b741600149..0000000000 --- a/scripts/repro/WALKPARTS.md +++ /dev/null @@ -1,42 +0,0 @@ -# `@intx/mime` leaf part defect - -**API path:** `extractPartByPath(raw, partPath)` (exported from -`@intx/mime`) → internal `walkParts` in `src/mime.ts`. - -**Input:** a `multipart/signed` message wrapping a `multipart/mixed` body -with one `text/plain` part and one attachment part (`Content-Transfer-Encoding: -base64`) — built with `assembleSignedContent({ kind: "conversation", ... })` - -- `assembleMessage(...)`. Fetched with `extractPartByPath(raw, "1.2")` - (part 1 = the signed content, part 2 = the attachment within it). - -**Expected:** the decoded attachment body, e.g. `"hello attachment body"`. - -**Actual:** - -``` -Content-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\nContent-Disposition: attachment; filename="note.txt"\r\n\r\naGVsbG8gYXR0YWNobWVudCBib2R5 -``` - -The leaf slice still carries its own header block and un-decoded base64 -body. - -**Offending code:** `src/mime.ts`, `walkParts`, the leaf-return branch: - -```ts -if (depth + 1 === steps.length) { - return partBytes; // src/mime.ts:738-739 -} -``` - -Every non-leaf depth calls `parseMimePart(partBytes)` to strip headers and -get `part.body` before recursing further (line 743), but the leaf case -skips that step and returns the raw `parseMultipart` slice untouched. - -**Suggested fix:** apply the same `parseMimePart` step to the leaf, then -transfer-decode `part.body` per its `Content-Transfer-Encoding` header -(base64/quoted-printable/7bit) before returning, so `extractPartByPath` -returns a decoded body at every depth, not just intermediate ones. - -Reproducer: `scripts/repro/walkparts-repro.ts` (`bun run -scripts/repro/walkparts-repro.ts`, no DB/network/env required). diff --git a/scripts/repro/connect-github-smoke.ts b/scripts/repro/connect-github-smoke.ts deleted file mode 100644 index f9dc535b62..0000000000 --- a/scripts/repro/connect-github-smoke.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Manual smoke test for the GitHub connect binding (hub-zero T3 - *): connects a real PAT through the native tenant-scoped - * `connections/github/complete` route against a running local hub. - * - * Hub-zero T3 deleted the workbench-scoped `github/state` and - * `github/start-reviewing` routes with no native equivalent yet, so this - * smoke stops after a successful connect — the card's repo-pick - * walkthrough rebind is a connections follow-up. - * - * Never prints or commits the token. Reads it from `GITHUB_TOKEN`, or - * falls back to `gh auth token` if that env var is unset. - * - * Required env: - * HUB_URL e.g. http://localhost:3000 - * TENANT_ID an existing tenant id this session can act as - * COOKIE the session cookie header value for an authenticated - * request (copy from a logged-in browser session) - * - * Run: bun run scripts/repro/connect-github-smoke.ts - */ -import { spawnSync } from "node:child_process"; - -function readToken(): string { - const fromEnv = process.env["GITHUB_TOKEN"]; - if (fromEnv !== undefined && fromEnv !== "") return fromEnv; - const result = spawnSync("gh", ["auth", "token"], { encoding: "utf8" }); - if (result.status !== 0 || result.stdout.trim() === "") { - throw new Error( - "No GITHUB_TOKEN in env and `gh auth token` failed — set one or run `gh auth login`.", - ); - } - return result.stdout.trim(); -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (value === undefined || value === "") { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} - -async function main() { - const hubUrl = requireEnv("HUB_URL"); - const tenantId = requireEnv("TENANT_ID"); - const cookie = requireEnv("COOKIE"); - const token = readToken(); - - const headers = { - "content-type": "application/json", - cookie, - }; - - console.log("== connect (native connections route) =="); - const connectRes = await fetch(`${hubUrl}/api/tenants/${tenantId}/connections/github/complete`, { - method: "POST", - headers, - body: JSON.stringify({ apiKey: token }), - }); - console.log(connectRes.status, await connectRes.text()); - if (!connectRes.ok) { - throw new Error("connect failed — see status above"); - } - // `github/state` and `github/start-reviewing` no longer exist, so - // there is nothing further to smoke until the connections follow-up - // lands the native rebind. - console.log("connected — state/start-reviewing walkthrough deleted"); -} - -main().catch((err: unknown) => { - console.error(err instanceof Error ? err.message : err); - process.exit(1); -}); diff --git a/scripts/reset.test.ts b/scripts/reset.test.ts deleted file mode 100644 index b1ed4160f4..0000000000 --- a/scripts/reset.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -// Unit gates for scripts/reset.ts's own logic: the local-database -// guard, on-disk directory resolution, and the orchestration in -// resetLocalState. The schema drop itself is @intx/db's dropSchema, -// already exercised through resetSchema in scripts/e2e/db-setup.test.ts; -// here resetSchema is a test double so this suite runs without Postgres. - -import { describe, expect, test } from "bun:test"; -import path from "node:path"; - -import { - requireLocalDatabase, - resetLocalState, - resolveLocalStateDirs, - type ResetDeps, -} from "./reset.ts"; - -const VALID_ENV = { - DATABASE_URL: "postgres://localhost:5432/workbench", - BASE_URL: "http://localhost:3000", - SESSION_SECRET: "a".repeat(32), - HUB_DATA_DIR: ".data/hub", - HUB_STATIC_DIR: "../web/dist", -}; - -describe("requireLocalDatabase", () => { - for (const hostname of ["localhost", "127.0.0.1", "::1"]) { - test(`accepts ${hostname}`, () => { - expect(() => - requireLocalDatabase( - `postgres://${hostname === "::1" ? "[::1]" : hostname}:5432/workbench`, - ), - ).not.toThrow(); - }); - } - - test("refuses a remote host", () => { - expect(() => requireLocalDatabase("postgres://prod.example.com:5432/workbench")).toThrow( - /not local/, - ); - }); -}); - -describe("resolveLocalStateDirs", () => { - test("resolves the hub dir against apps/hub", () => { - const dirs = resolveLocalStateDirs("/repo", ".data/hub"); - expect(dirs.hubDataDir).toBe(path.resolve("/repo/apps/hub/.data/hub")); - }); - - test("honors an absolute HUB_DATA_DIR", () => { - const dirs = resolveLocalStateDirs("/repo", "/var/workbench-data"); - expect(dirs.hubDataDir).toBe("/var/workbench-data"); - }); -}); - -describe("resetLocalState", () => { - test("refuses a non-local DATABASE_URL before touching anything", async () => { - let resetSchemaCalls = 0; - const deps: ResetDeps = { - root: "/repo", - resetSchema: async () => { - resetSchemaCalls += 1; - }, - rm: async () => undefined, - exists: () => true, - }; - await expect( - resetLocalState( - { ...VALID_ENV, DATABASE_URL: "postgres://prod.example.com/workbench" }, - deps, - ), - ).rejects.toThrow(/not local/); - expect(resetSchemaCalls).toBe(0); - }); - - test("drops the schema and removes only the directories that exist", async () => { - const schemaCalls: string[] = []; - const removed: string[] = []; - const present = new Set([path.resolve("/repo/apps/hub/.data/hub")]); - const deps: ResetDeps = { - root: "/repo", - resetSchema: async (databaseUrl) => { - schemaCalls.push(databaseUrl); - }, - rm: async (dir) => { - removed.push(String(dir)); - }, - exists: (dir) => present.has(String(dir)), - }; - - const report = await resetLocalState(VALID_ENV, deps); - - expect(schemaCalls).toEqual([VALID_ENV.DATABASE_URL]); - expect(removed).toEqual([path.resolve("/repo/apps/hub/.data/hub")]); - expect(report.database).toBe("workbench"); - expect(report.removedDirs).toEqual([path.resolve("/repo/apps/hub/.data/hub")]); - }); - - test("reports no removed directories when it is not present", async () => { - const deps: ResetDeps = { - root: "/repo", - resetSchema: async () => undefined, - rm: async () => undefined, - exists: () => false, - }; - const report = await resetLocalState(VALID_ENV, deps); - expect(report.removedDirs).toEqual([]); - }); -}); diff --git a/scripts/run-all.test.ts b/scripts/run-all.test.ts deleted file mode 100644 index d2fe7fa6d3..0000000000 --- a/scripts/run-all.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// Drives the workspace script runner through its real command line against a -// throwaway fixture workspace. This runner gates every commit for everyone, so -// a swallowed exit code or a package silently skipped here would break the -// merge gate with nothing to catch it. -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, join } from "node:path"; - -import { resolveConcurrency } from "./run-all.ts"; - -const RUNNER = join(import.meta.dir, "run-all.ts"); - -// Each probe brackets a sleep with a start/end line in a shared log, which is -// what lets a test reconstruct how many probes were in flight at once without -// depending on wall-clock timing. -const PROBE_SOURCE = `import { appendFile } from "node:fs/promises"; -import { basename } from "node:path"; - -const log = process.env["PROBE_LOG"] ?? ""; -const name = basename(process.cwd()); - -await appendFile(log, \`start:\${name}\\n\`); -await Bun.sleep(250); -await appendFile(log, \`end:\${name}\\n\`); -console.log(\`probe ran in \${name}\`); - -if (process.env["PROBE_FAIL"] === name) process.exit(1); -`; - -const WITH_PROBE = ["alpha", "bravo", "charlie", "delta"] as const; -const WITHOUT_PROBE = "echo-only"; -// The root gate's own "test" script fans out across packages exactly -// like any other script now (see run-all.ts's resolveConcurrency); it -// gets its own probe run below purely to prove that specific script name -// carries no special-cased concurrency any more. -const TEST_SCRIPT = "test"; - -let workspace = ""; -let logCounter = 0; - -async function writePackage( - root: string, - name: string, - scripts: Record, -): Promise { - const dir = join(root, "packages", name); - await mkdir(dir, { recursive: true }); - await writeFile( - join(dir, "package.json"), - JSON.stringify({ name: `@fixture/${name}`, scripts }, null, 2), - ); -} - -type RunnerResult = { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; - readonly log: string; -}; - -async function runProbe( - extraEnv: Record = {}, - script = "probe", - extraArgs: readonly string[] = [], -): Promise { - logCounter += 1; - const logPath = join(workspace, `probe-${logCounter}.log`); - await writeFile(logPath, ""); - - // The parent process may have WORKBENCH_CHECK_CONCURRENCY set for the - // gate itself. Default-concurrency probes must not inherit it; tests that - // intend an override pass the var in extraEnv. - const env: NodeJS.ProcessEnv = { - ...process.env, - PROBE_LOG: logPath, - ...extraEnv, - }; - if (!Object.hasOwn(extraEnv, "WORKBENCH_CHECK_CONCURRENCY")) { - delete env["WORKBENCH_CHECK_CONCURRENCY"]; - } - - const child = Bun.spawn(["bun", "run", RUNNER, script, ...extraArgs], { - cwd: workspace, - env, - stdout: "pipe", - stderr: "pipe", - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - - return { exitCode, stdout, stderr, log: await readFile(logPath, "utf8") }; -} - -/** Peak number of probes running at the same moment, from the shared log. */ -function peakOverlap(log: string): number { - let current = 0; - let peak = 0; - for (const line of log.split("\n")) { - if (line.startsWith("start:")) { - current += 1; - peak = Math.max(peak, current); - } else if (line.startsWith("end:")) { - current -= 1; - } - } - return peak; -} - -describe("run-all", () => { - beforeAll(async () => { - workspace = await mkdtemp(join(tmpdir(), "workbench-run-all-")); - for (const name of WITH_PROBE) { - await writePackage(workspace, name, { - probe: "bun run probe.ts", - [TEST_SCRIPT]: "bun run probe.ts", - }); - await writeFile(join(workspace, "packages", name, "probe.ts"), PROBE_SOURCE); - } - await writePackage(workspace, WITHOUT_PROBE, { other: "true" }); - - const vendorDir = join(workspace, "vendor", "intx", "vendor-probe"); - await mkdir(vendorDir, { recursive: true }); - await writeFile( - join(vendorDir, "package.json"), - JSON.stringify( - { name: "@intx/vendor-probe", scripts: { probe: "bun run probe.ts" } }, - null, - 2, - ), - ); - await writeFile(join(vendorDir, "probe.ts"), PROBE_SOURCE); - }); - - afterAll(async () => { - if (workspace !== "") await rm(workspace, { recursive: true, force: true }); - }); - - test("runs the script in every package that defines it, and only those", async () => { - const result = await runProbe(); - - for (const name of WITH_PROBE) { - expect(result.stdout).toContain(`probe ran in ${name}`); - } - expect(result.stdout).not.toContain(WITHOUT_PROBE); - expect(result.exitCode).toBe(0); - }); - - test("runs the script in vendored packages under vendor/intx too", async () => { - const result = await runProbe(); - - expect(result.stdout).toContain("probe ran in vendor-probe"); - expect(result.exitCode).toBe(0); - }); - - test("runs packages concurrently rather than one at a time", async () => { - const result = await runProbe({ WORKBENCH_CHECK_CONCURRENCY: "4" }); - - expect(peakOverlap(result.log)).toBeGreaterThan(1); - }); - - test("never exceeds the configured concurrency", async () => { - const result = await runProbe({ WORKBENCH_CHECK_CONCURRENCY: "2" }); - - expect(peakOverlap(result.log)).toBeLessThanOrEqual(2); - for (const name of WITH_PROBE) { - expect(result.stdout).toContain(`probe ran in ${name}`); - } - }); - - test("runs the test script concurrently like any other script", async () => { - const result = await runProbe({ WORKBENCH_CHECK_CONCURRENCY: "4" }, TEST_SCRIPT); - - expect(peakOverlap(result.log)).toBeGreaterThan(1); - for (const name of WITH_PROBE) { - expect(result.stdout).toContain(`probe ran in ${name}`); - } - }); - - test("honours an explicit concurrency for the test script", async () => { - const result = await runProbe({ WORKBENCH_CHECK_CONCURRENCY: "3" }, TEST_SCRIPT); - - expect(peakOverlap(result.log)).toBeGreaterThan(1); - }); - - test("rejects a concurrency setting that is not a positive integer", async () => { - const result = await runProbe({ WORKBENCH_CHECK_CONCURRENCY: "0" }); - - expect(result.exitCode).not.toBe(0); - expect(result.stderr).toContain("WORKBENCH_CHECK_CONCURRENCY"); - }); - - test("uses every core in GitHub Actions and leaves two free locally", () => { - expect(resolveConcurrency("typecheck", {}, 8)).toBe(6); - expect(resolveConcurrency("typecheck", { GITHUB_ACTIONS: "true" }, 8)).toBe(8); - expect( - resolveConcurrency( - "typecheck", - { GITHUB_ACTIONS: "true", WORKBENCH_CHECK_CONCURRENCY: "3" }, - 8, - ), - ).toBe(3); - expect(resolveConcurrency("test", { GITHUB_ACTIONS: "true" }, 8)).toBe(8); - expect(resolveConcurrency("test", {}, 8)).toBe(6); - }); - - test("fails the run and names the package whose script failed", async () => { - const result = await runProbe({ PROBE_FAIL: "charlie" }); - - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("@fixture/charlie"); - }); - - test("keeps a failing package from cancelling its siblings", async () => { - const result = await runProbe({ PROBE_FAIL: "charlie" }); - - for (const name of WITH_PROBE) { - expect(result.stdout).toContain(`probe ran in ${name}`); - } - }); - - test("attributes each package's output to that package", async () => { - const result = await runProbe(); - - for (const name of WITH_PROBE) { - const header = result.stdout.indexOf(`@fixture/${name}`); - const output = result.stdout.indexOf(`probe ran in ${name}`); - expect(header).toBeGreaterThanOrEqual(0); - expect(header).toBeLessThan(output); - } - }); - - test("reports when no package defines the script", async () => { - const child = Bun.spawn(["bun", "run", RUNNER, "nothing-defines-this"], { - cwd: workspace, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]); - - expect(exitCode).toBe(0); - expect(stdout).toContain("no workspace packages define it"); - }); - - test("fixture workspace holds only the packages the tests declare", () => { - expect(basename(workspace).startsWith("workbench-run-all-")).toBe(true); - }); -}); diff --git a/scripts/setup-memory.test.ts b/scripts/setup-memory.test.ts deleted file mode 100644 index 2123560ea1..0000000000 --- a/scripts/setup-memory.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - applyEnvKeysToDotenvContents, - dotenvHasActiveKey, - embedEnvKeysForDotenv, - localDevMemoryEmbedEnv, - planEmbedding, - planRerank, -} from "./setup-memory"; - -describe("planEmbedding", () => { - test("recommends native Ollama first when it's on PATH", () => { - const plan = planEmbedding({ hasNativeOllama: true, hasDocker: true }); - expect(plan.strategy).toBe("native-ollama"); - expect(plan.env["EMBED_BASE_URL"]).toBe("http://localhost:11434"); - expect(plan.env["EMBED_API_STYLE"]).toBe("ollama"); - }); - - test("falls back to a Dockerized Ollama when native isn't available but Docker is", () => { - const plan = planEmbedding({ hasNativeOllama: false, hasDocker: true }); - expect(plan.strategy).toBe("docker-ollama"); - expect(plan.env["EMBED_API_STYLE"]).toBe("ollama"); - }); - - test("recommends a remote endpoint when neither native Ollama nor Docker is available", () => { - const plan = planEmbedding({ hasNativeOllama: false, hasDocker: false }); - expect(plan.strategy).toBe("endpoint"); - expect(plan.env).toEqual({}); - expect(plan.instructions.join("\n")).toMatch(/EMBED_BASE_URL/); - }); -}); - -describe("planRerank", () => { - test("recommends the Dockerized TEI reranker when Docker is available", () => { - const plan = planRerank({ hasNativeOllama: true, hasDocker: true }); - expect(plan.strategy).toBe("docker-tei"); - expect(plan.env["RERANK_BASE_URL"]).toBe("http://localhost:8081"); - expect(plan.env["RERANK_MODEL"]).toBe("BAAI/bge-reranker-base"); - }); - - test("recommends a remote endpoint when Docker is unavailable, never a native path", () => { - const plan = planRerank({ hasNativeOllama: true, hasDocker: false }); - expect(plan.strategy).toBe("endpoint"); - expect(plan.env).toEqual({}); - expect(plan.instructions.join("\n")).toMatch(/RERANK_BASE_URL/); - }); - - test("is explicit that skipping reranking still leaves search working", () => { - const plan = planRerank({ hasNativeOllama: false, hasDocker: false }); - expect(plan.instructions.join("\n")).toMatch(/search still works/i); - }); -}); - -describe("localDevMemoryEmbedEnv", () => { - test("injects native Ollama embed env when nothing is configured and ollama is on PATH", () => { - expect(localDevMemoryEmbedEnv({}, { hasNativeOllama: true })).toEqual({ - EMBED_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "nomic-embed-text", - EMBED_API_STYLE: "ollama", - }); - }); - - test("does not inject when EMBED_BASE_URL is already set", () => { - expect( - localDevMemoryEmbedEnv( - { EMBED_BASE_URL: "https://api.openai.com/v1" }, - { hasNativeOllama: true }, - ), - ).toBeUndefined(); - }); - - test("does not inject when OLLAMA_BASE_URL is already set — mountMemory uses it", () => { - expect( - localDevMemoryEmbedEnv( - { OLLAMA_BASE_URL: "http://localhost:11434" }, - { hasNativeOllama: true }, - ), - ).toBeUndefined(); - }); - - test("does not inject when native Ollama is absent", () => { - expect(localDevMemoryEmbedEnv({}, { hasNativeOllama: false })).toBeUndefined(); - }); -}); - -describe("embedEnvKeysForDotenv", () => { - const localEmbed = { - EMBED_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "nomic-embed-text", - EMBED_API_STYLE: "ollama", - }; - - test("omits localhost EMBED_BASE_URL when OLLAMA_BASE_URL is already set", () => { - const existing = "OLLAMA_BASE_URL=https://home-mac.example.ts.net\n"; - const keys = embedEnvKeysForDotenv(existing, localEmbed); - expect(keys["EMBED_BASE_URL"]).toBeUndefined(); - expect(keys["EMBED_MODEL"]).toBe("nomic-embed-text"); - expect(keys["EMBED_API_STYLE"]).toBe("ollama"); - const { next, added } = applyEnvKeysToDotenvContents(existing, keys); - expect(added).not.toContain("EMBED_BASE_URL"); - expect(next).not.toContain("EMBED_BASE_URL=http://localhost:11434"); - expect(next).toContain("OLLAMA_BASE_URL=https://home-mac.example.ts.net"); - }); - - test("still plants localhost EMBED_BASE_URL when OLLAMA_BASE_URL is blank", () => { - const existing = "OLLAMA_BASE_URL=\n"; - const keys = embedEnvKeysForDotenv(existing, localEmbed); - expect(keys["EMBED_BASE_URL"]).toBe("http://localhost:11434"); - }); - - test("leaves keys unchanged when EMBED_BASE_URL is already set", () => { - const existing = - "OLLAMA_BASE_URL=https://home-mac.example.ts.net\nEMBED_BASE_URL=https://api.openai.com/v1\n"; - expect(embedEnvKeysForDotenv(existing, localEmbed)).toEqual(localEmbed); - }); - - test("plants localhost EMBED_BASE_URL when OLLAMA_BASE_URL is absent", () => { - expect(embedEnvKeysForDotenv("", localEmbed)).toEqual(localEmbed); - }); -}); - -describe("applyEnvKeysToDotenvContents", () => { - test("appends missing keys and ignores commented-out lines", () => { - const existing = "# EMBED_BASE_URL=http://example\nDATABASE_URL=postgres://x\n"; - const { next, added } = applyEnvKeysToDotenvContents(existing, { - EMBED_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "nomic-embed-text", - }); - expect(added).toEqual(["EMBED_BASE_URL", "EMBED_MODEL"]); - expect(next).toContain("EMBED_BASE_URL=http://localhost:11434"); - expect(dotenvHasActiveKey(next, "EMBED_BASE_URL")).toBe(true); - }); - - test("does not overwrite an active key", () => { - const existing = "EMBED_BASE_URL=https://api.openai.com/v1\n"; - const { next, added } = applyEnvKeysToDotenvContents(existing, { - EMBED_BASE_URL: "http://localhost:11434", - EMBED_MODEL: "nomic-embed-text", - }); - expect(added).toEqual(["EMBED_MODEL"]); - expect(next).toContain("EMBED_BASE_URL=https://api.openai.com/v1"); - expect(next).not.toContain("EMBED_BASE_URL=http://localhost:11434"); - }); -}); diff --git a/scripts/typecheck.ts b/scripts/typecheck.ts index 3f55ada5db..b2a46d7ef5 100644 --- a/scripts/typecheck.ts +++ b/scripts/typecheck.ts @@ -6,8 +6,7 @@ // workspace dependency. // // `tsc --build` is invoked against a single generated solution file -// (`tsconfig.build.json`, a `references`-only manifest kept in sync by -// scripts/generate-tsconfig-references.ts), not against every composite +// (`tsconfig.build.json`, a `references`-only manifest), not against every composite // project's tsconfig.src.json as separate command-line roots. Handing it // many unrelated roots in one invocation was tried first and produces // incorrect results: `tsc --build` shares source-file/diagnostic state @@ -18,9 +17,9 @@ // gets the same dependency-order build with the same up-to-date skipping. // // Every package's combined `tsconfig.json` (src + test together) is then -// checked separately, in parallel -- see scripts/generate-tsconfig-references.ts -// for why the composite src-only project (`tsconfig.src.json`, used only by -// the build above and by composite dependents' `references`) can't include +// checked separately, in parallel -- the composite src-only project +// (`tsconfig.src.json`, used only by the build above and by composite +// dependents' `references`) can't include // test files itself. This is the same shape for every package regardless // of whether it has a `tsconfig.src.json` at all: a package excluded from // the composite graph entirely (a real dependency cycle, or a transitive diff --git a/test/isolation/isolation.test.ts b/test/isolation/isolation.test.ts index 07b0542305..890a198cd2 100644 --- a/test/isolation/isolation.test.ts +++ b/test/isolation/isolation.test.ts @@ -65,7 +65,7 @@ if (!databaseUrl) { const hub = await bootIsolationHub(databaseUrl); const app: AppLike = hub.app; // `hub.shutdown()` stops the sidecar then the hub in sequence - // (`test/isolation/setup.ts`), and each `stop()` (`scripts/e2e/ + // (`test/isolation/setup.ts`), and each `stop()` (`e2e/ // harness.ts`) gives its process up to 5s to exit on SIGTERM before // escalating to SIGKILL — so a graceful-but-slow exit on either // process alone can approach Bun's 5000ms default hook timeout, and diff --git a/test/isolation/setup.ts b/test/isolation/setup.ts index ffd9ba5d45..45e3e77ca6 100644 --- a/test/isolation/setup.ts +++ b/test/isolation/setup.ts @@ -8,8 +8,8 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { freePort, startHub, type HubHandle } from "../../scripts/e2e/harness.ts"; -import { assertDatabaseConfigured } from "../../scripts/e2e/db-gate.ts"; +import { freePort, startHub, type HubHandle } from "../../e2e/harness.ts"; +import { assertDatabaseConfigured } from "../../e2e/db-gate.ts"; /** * The minimal surface of the hub the suite needs: a fetch-shaped @@ -82,7 +82,7 @@ export async function prepareDatabase(databaseUrl: string): Promise { /** * Boots the real hub as a spawned process, exactly as - * `scripts/e2e/harness.ts` does for the walking skeleton — a + * `e2e/harness.ts` does for the walking skeleton — a * chat-workbench create launches a real agent instance over its own * process-provisioner-spawned sidecar's WebSocket dial-in, so an * in-process hub with no sidecar plugin wired up cannot exercise the @@ -127,7 +127,7 @@ export async function bootIsolationHub(databaseUrl: string): Promise