Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

305 lines
11 KiB
TypeScript

/**
* EmDashRuntime.create() — cold-boot initialization
*
* Exercises the full static create() path end-to-end against a real
* in-memory SQLite database: migrations, the parallelized plugin-state +
* site-info reads, pipeline creation, batched exclusive hook resolution,
* and cron init. Asserts that the per-phase timing instrumentation still
* records every phase individually after parallelization.
*/
import { randomUUID } from "node:crypto";
import Database from "better-sqlite3";
import { Kysely, sql, SqliteDialect } from "kysely";
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID } from "../../../src/comments/moderator.js";
import { runMigrations } from "../../../src/database/migrations/runner.js";
import type { Database as EmDashDatabase } from "../../../src/database/types.js";
import { EmDashRuntime } from "../../../src/emdash-runtime.js";
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
import { definePlugin } from "../../../src/plugins/define-plugin.js";
import type { ContentBeforeSaveHandler } from "../../../src/plugins/types.js";
import { runWithContext } from "../../../src/request-context.js";
function createDeps(): RuntimeDependencies {
return {
config: {
database: {
// Unique entrypoint per test so the module-level dbCache in
// emdash-runtime.ts never serves a stale instance across tests.
entrypoint: `test-runtime-create-${randomUUID()}`,
config: {},
type: "sqlite",
},
},
plugins: [
definePlugin({
id: "test-exclusive-provider",
version: "1.0.0",
capabilities: ["content:write", "content:read"],
hooks: {
"content:beforeSave": {
exclusive: true,
handler: vi.fn() as unknown as ContentBeforeSaveHandler,
},
},
}),
],
createDialect: () => new SqliteDialect({ database: new Database(":memory:") }),
createStorage: null,
sandboxEnabled: false,
sandboxedPluginEntries: [],
createSandboxRunner: null,
};
}
describe("EmDashRuntime.create — cold boot", () => {
it("initializes end-to-end and records each phase's own timing", async () => {
const timings: Array<{ name: string; dur: number; desc?: string }> = [];
const runtime = await EmDashRuntime.create(createDeps(), timings);
try {
// Every phase records its own entry exactly once.
const names = timings.map((t) => t.name);
for (const expected of [
"rt.db",
"rt.seedcheck",
"rt.plugins",
"rt.site",
"rt.sandbox",
"rt.hooks",
"rt.cron",
]) {
expect(names.filter((n) => n === expected)).toHaveLength(1);
}
// rt.market / rt.registry are not configured — no phantom phases.
expect(names).not.toContain("rt.market");
expect(names).not.toContain("rt.registry");
for (const t of timings) {
expect(t.dur).toBeGreaterThanOrEqual(0);
expect(Number.isFinite(t.dur)).toBe(true);
}
// Exclusive hooks resolved: sole providers auto-selected, both in
// memory and persisted to the options table.
expect(runtime.hooks.getExclusiveSelection("content:beforeSave")).toBe(
"test-exclusive-provider",
);
expect(runtime.hooks.getExclusiveSelection("comment:moderate")).toBe(
DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
);
const row = await runtime.db
.selectFrom("options")
.select("value")
.where("name", "=", "emdash:exclusive_hook:content:beforeSave")
.executeTakeFirst();
expect(row).toBeDefined();
expect(JSON.parse(row!.value)).toBe("test-exclusive-provider");
} finally {
await runtime.stopCron();
}
});
it("creates a runtime without a timings array (backwards compatible)", async () => {
const runtime = await EmDashRuntime.create(createDeps());
try {
expect(runtime.hooks.getExclusiveSelection("content:beforeSave")).toBe(
"test-exclusive-provider",
);
} finally {
await runtime.stopCron();
}
});
// The init read phase feeds plugin enablement: a plugin marked inactive in
// _plugin_state must be excluded from the pipeline, so its exclusive hook is
// never auto-selected. A shared DB seeds the _plugin_state row before
// create() reads it.
it("excludes a plugin disabled in _plugin_state from the initial pipeline", async () => {
const sqlite = new Database(":memory:");
const setupDb = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
});
await runMigrations(setupDb);
await setupDb
.insertInto("_plugin_state")
.values({ plugin_id: "test-exclusive-provider", version: "1.0.0", status: "inactive" })
.execute();
// Mark setup complete so create() doesn't attempt the (test-unavailable)
// virtual seed module; keeps the run focused on the plugin-state read.
await setupDb
.insertInto("options")
.values({ name: "emdash:setup_complete", value: "true" })
.execute();
const deps: RuntimeDependencies = {
...createDeps(),
createDialect: () => new SqliteDialect({ database: sqlite }),
};
const runtime = await EmDashRuntime.create(deps);
try {
// Disabled provider is not in the pipeline -> no candidate -> unselected.
expect(runtime.hooks.getExclusiveSelection("content:beforeSave")).toBeUndefined();
// The always-enabled built-in moderator is unaffected.
expect(runtime.hooks.getExclusiveSelection("comment:moderate")).toBe(
DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
);
} finally {
await runtime.stopCron();
await setupDb.destroy();
}
});
// When a coalescing dialect is provided, the cold-start read phase must run
// on it (one batched round trip), not the singleton. Prove the routing: the
// coalescing db marks the provider inactive while the singleton leaves it
// enabled, so reading from the coalescing db excludes the provider and
// leaves its exclusive hook unselected.
it("routes the cold-start read phase through createCoalescingDialect", async () => {
const singletonSqlite = new Database(":memory:");
const singletonSetup = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: singletonSqlite }),
});
await runMigrations(singletonSetup);
await singletonSetup
.insertInto("options")
.values({ name: "emdash:setup_complete", value: "true" })
.execute();
const coalescingSqlite = new Database(":memory:");
const coalescingSetup = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: coalescingSqlite }),
});
await runMigrations(coalescingSetup);
await coalescingSetup
.insertInto("_plugin_state")
.values({ plugin_id: "test-exclusive-provider", version: "1.0.0", status: "inactive" })
.execute();
await coalescingSetup
.insertInto("options")
.values({ name: "emdash:setup_complete", value: "true" })
.execute();
let coalescingCalls = 0;
const deps: RuntimeDependencies = {
...createDeps(),
createDialect: () => new SqliteDialect({ database: singletonSqlite }),
createCoalescingDialect: () => {
coalescingCalls += 1;
return new SqliteDialect({ database: coalescingSqlite });
},
};
const runtime = await EmDashRuntime.create(deps);
try {
// The read phase built exactly one coalescing connection...
expect(coalescingCalls).toBe(1);
// ...and read plugin state from it (provider inactive there), so the
// provider is excluded and its exclusive hook is unselected. Reading
// from the singleton (provider enabled) would have selected it.
expect(runtime.hooks.getExclusiveSelection("content:beforeSave")).toBeUndefined();
} finally {
await runtime.stopCron();
// create() destroys the read connection, which closes coalescingSqlite;
// the setup handles may already be closed.
try {
await singletonSetup.destroy();
} catch {
// already closed
}
try {
await coalescingSetup.destroy();
} catch {
// already closed
}
}
});
// A failed migration must not be retried on every create() call: on
// Workers each request in a warm isolate re-enters create(), and without
// a backoff every request re-runs the failing migration against the
// database (#1744). The failure is remembered per database entrypoint
// and re-attempts are skipped for a backoff window.
it("backs off after a migration failure instead of retrying immediately", async () => {
// A database whose next migration deterministically fails: fully
// migrated, then the 001_initial bookkeeping row is removed so the
// migrator re-runs it against existing tables.
const sqlite = new Database(":memory:");
const setupDb = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
});
await runMigrations(setupDb);
await sql`DELETE FROM _emdash_migrations WHERE name = '001_initial'`.execute(setupDb);
let dialectCalls = 0;
const deps: RuntimeDependencies = {
...createDeps(),
createDialect: () => {
dialectCalls += 1;
return new SqliteDialect({ database: sqlite });
},
};
await expect(EmDashRuntime.create(deps)).rejects.toThrow(/Migration failed/i);
expect(dialectCalls).toBe(1);
// An immediate retry (same entrypoint → same failure record) must
// fail fast without building a new connection or touching the db.
await expect(EmDashRuntime.create(deps)).rejects.toThrow(/backing off/i);
expect(dialectCalls).toBe(1);
});
// A per-request isolated db (playground / DO preview) must never be
// auto-seeded. With an isolated, empty, not-set-up db, a broken guard would
// run the gate (rt.seedcheck appears) and attempt a seed; assert neither
// happens.
it("does not run the auto-seed gate for an isolated request db", async () => {
const sqlite = new Database(":memory:");
const isolated = new Kysely<EmDashDatabase>({
dialect: new SqliteDialect({ database: sqlite }),
});
await runMigrations(isolated);
// Sentinel: mark the configured provider inactive ONLY in the isolated
// db. If create() reads plugin state from this db (proving it honored
// ctx.db), the provider is excluded and its exclusive hook is unselected.
// If it silently used a different db, the provider would be selected and
// the assertion below would fail — so this proves the routing, which a
// bare "collections still 0" check cannot.
await isolated
.insertInto("_plugin_state")
.values({ plugin_id: "test-exclusive-provider", version: "1.0.0", status: "inactive" })
.execute();
// Empty + not set up: this is exactly the state that WOULD seed on the
// configured-singleton path. The guard must skip it for an isolated db.
const before = await isolated
.selectFrom("_emdash_collections")
.select((eb) => eb.fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
expect(before.count).toBe(0);
const timings: Array<{ name: string; dur: number; desc?: string }> = [];
const runtime = await runWithContext(
{ editMode: false, db: isolated, dbIsIsolated: true },
() => EmDashRuntime.create(createDeps(), timings),
);
try {
// create() read plugin state from the ISOLATED db (honored ctx.db):
// the sentinel inactive row excluded the provider from the pipeline.
expect(runtime.hooks.getExclusiveSelection("content:beforeSave")).toBeUndefined();
// Guard skipped the gate entirely: no rt.seedcheck phase.
expect(timings.map((t) => t.name)).not.toContain("rt.seedcheck");
// And nothing was seeded onto the borrowed db.
const after = await isolated
.selectFrom("_emdash_collections")
.select((eb) => eb.fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
expect(after.count).toBe(0);
} finally {
await runtime.stopCron();
await isolated.destroy();
}
});
});