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
347 lines
7.3 KiB
TypeScript
347 lines
7.3 KiB
TypeScript
/**
|
|
* Workerd Integration Tests
|
|
*
|
|
* These tests spawn a real workerd process and exercise the full plugin
|
|
* lifecycle: load, invoke hooks/routes, unload, and error handling.
|
|
*
|
|
* Skipped if the workerd binary is not available (e.g., in CI without
|
|
* the workerd package installed).
|
|
*/
|
|
|
|
import Database from "better-sqlite3";
|
|
import { Kysely, SqliteDialect } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import { WorkerdSandboxRunner } from "../src/sandbox/runner.js";
|
|
|
|
// Check at module level so describe.skipIf works
|
|
let workerdAvailable = false;
|
|
try {
|
|
const testRunner = new WorkerdSandboxRunner({ db: null as any });
|
|
workerdAvailable = testRunner.isAvailable();
|
|
} catch {
|
|
// workerd not available
|
|
}
|
|
|
|
function createTestDb() {
|
|
const sqlite = new Database(":memory:");
|
|
const db = new Kysely<any>({
|
|
dialect: new SqliteDialect({ database: sqlite }),
|
|
});
|
|
return { db, sqlite };
|
|
}
|
|
|
|
async function setupTables(db: Kysely<any>) {
|
|
await db.schema
|
|
.createTable("_plugin_storage")
|
|
.addColumn("plugin_id", "text", (col) => col.notNull())
|
|
.addColumn("collection", "text", (col) => col.notNull())
|
|
.addColumn("id", "text", (col) => col.notNull())
|
|
.addColumn("data", "text", (col) => col.notNull())
|
|
.addColumn("created_at", "text", (col) => col.notNull())
|
|
.addColumn("updated_at", "text", (col) => col.notNull())
|
|
.addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"])
|
|
.execute();
|
|
|
|
await db.schema
|
|
.createTable("ec_posts")
|
|
.addColumn("id", "text", (col) => col.primaryKey())
|
|
.addColumn("slug", "text")
|
|
.addColumn("status", "text", (col) => col.defaultTo("draft"))
|
|
.addColumn("title", "text")
|
|
.addColumn("author_id", "text")
|
|
.addColumn("created_at", "text")
|
|
.addColumn("updated_at", "text")
|
|
.addColumn("published_at", "text")
|
|
.addColumn("scheduled_at", "text")
|
|
.addColumn("deleted_at", "text")
|
|
.addColumn("version", "integer", (col) => col.defaultTo(1))
|
|
.addColumn("live_revision_id", "text")
|
|
.addColumn("draft_revision_id", "text")
|
|
.execute();
|
|
}
|
|
|
|
/** Minimal plugin code that echoes back hook/route calls.
|
|
* Route handlers receive { input, request, requestMeta } as first arg. */
|
|
const ECHO_PLUGIN = `
|
|
export default {
|
|
hooks: {
|
|
"content:beforeSave": {
|
|
handler: async (event, ctx) => {
|
|
await ctx.kv.set("last-hook", JSON.stringify({ hook: "content:beforeSave", event }));
|
|
return event;
|
|
}
|
|
}
|
|
},
|
|
routes: {
|
|
"echo": {
|
|
handler: async (routeCtx, ctx) => {
|
|
const kvValue = await ctx.kv.get("last-hook");
|
|
return { input: routeCtx.input, kvValue };
|
|
}
|
|
},
|
|
"kv-test": {
|
|
handler: async (routeCtx, ctx) => {
|
|
await ctx.kv.set("test-key", routeCtx.input.value);
|
|
const result = await ctx.kv.get("test-key");
|
|
return { stored: result };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
`;
|
|
|
|
/** Plugin that sleeps longer than the wall-time limit */
|
|
const SLOW_PLUGIN = `
|
|
export default {
|
|
hooks: {},
|
|
routes: {
|
|
"slow": {
|
|
handler: async () => {
|
|
await new Promise(r => setTimeout(r, 60000));
|
|
return { done: true };
|
|
}
|
|
}
|
|
}
|
|
};
|
|
`;
|
|
|
|
describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => {
|
|
let db: Kysely<any>;
|
|
let sqlite: Database.Database;
|
|
let runner: WorkerdSandboxRunner;
|
|
|
|
beforeEach(async () => {
|
|
const testDb = createTestDb();
|
|
db = testDb.db;
|
|
sqlite = testDb.sqlite;
|
|
await setupTables(db);
|
|
|
|
runner = new WorkerdSandboxRunner({ db });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await runner.terminateAll();
|
|
await db.destroy();
|
|
sqlite.close();
|
|
});
|
|
|
|
it("loads a plugin and invokes a route", async () => {
|
|
const plugin = await runner.load(
|
|
{
|
|
id: "test-echo",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const result = (await plugin.invokeRoute(
|
|
"echo",
|
|
{ hello: "world" },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
)) as any;
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.input).toEqual({ hello: "world" });
|
|
}, 30_000);
|
|
|
|
it("loads a plugin and invokes a hook", async () => {
|
|
const plugin = await runner.load(
|
|
{
|
|
id: "test-echo",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const result = await plugin.invokeHook("content:beforeSave", {
|
|
content: { title: "Test" },
|
|
});
|
|
|
|
expect(result).toBeDefined();
|
|
|
|
// Verify KV was written via the hook
|
|
const kvResult = (await plugin.invokeRoute(
|
|
"echo",
|
|
{},
|
|
{
|
|
method: "GET",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
)) as any;
|
|
|
|
expect(kvResult.kvValue).toBeTruthy();
|
|
const parsed = JSON.parse(kvResult.kvValue);
|
|
expect(parsed.hook).toBe("content:beforeSave");
|
|
}, 30_000);
|
|
|
|
it("enforces KV isolation between plugins via routes", async () => {
|
|
const plugin = await runner.load(
|
|
{
|
|
id: "test-kv",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const result = (await plugin.invokeRoute(
|
|
"kv-test",
|
|
{ value: "hello" },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
)) as any;
|
|
|
|
expect(result.stored).toBe("hello");
|
|
}, 30_000);
|
|
|
|
it("handles plugin unload and reload", async () => {
|
|
const plugin1 = await runner.load(
|
|
{
|
|
id: "test-reload",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
// Invoke to verify it works
|
|
const result1 = (await plugin1.invokeRoute(
|
|
"echo",
|
|
{ v: 1 },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
)) as any;
|
|
expect(result1.input.v).toBe(1);
|
|
|
|
// Unload
|
|
await plugin1.terminate();
|
|
|
|
// Reload with new version
|
|
const plugin2 = await runner.load(
|
|
{
|
|
id: "test-reload",
|
|
version: "2.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const result2 = (await plugin2.invokeRoute(
|
|
"echo",
|
|
{ v: 2 },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
)) as any;
|
|
expect(result2.input.v).toBe(2);
|
|
}, 60_000);
|
|
|
|
it("enforces wall-time limit", async () => {
|
|
const slowRunner = new WorkerdSandboxRunner({
|
|
db,
|
|
limits: { wallTimeMs: 2000 },
|
|
});
|
|
|
|
try {
|
|
const plugin = await slowRunner.load(
|
|
{
|
|
id: "test-slow",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
SLOW_PLUGIN,
|
|
);
|
|
|
|
await expect(
|
|
plugin.invokeRoute(
|
|
"slow",
|
|
{},
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
),
|
|
).rejects.toThrow(/exceeded wall-time limit/);
|
|
} finally {
|
|
await slowRunner.terminateAll();
|
|
}
|
|
}, 30_000);
|
|
|
|
it("loads multiple plugins simultaneously", async () => {
|
|
const plugin1 = await runner.load(
|
|
{
|
|
id: "test-multi-a",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const plugin2 = await runner.load(
|
|
{
|
|
id: "test-multi-b",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
},
|
|
ECHO_PLUGIN,
|
|
);
|
|
|
|
const [r1, r2] = (await Promise.all([
|
|
plugin1.invokeRoute(
|
|
"echo",
|
|
{ from: "a" },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
),
|
|
plugin2.invokeRoute(
|
|
"echo",
|
|
{ from: "b" },
|
|
{
|
|
method: "POST",
|
|
url: "/api/test",
|
|
headers: {},
|
|
},
|
|
),
|
|
])) as any[];
|
|
|
|
expect(r1.input.from).toBe("a");
|
|
expect(r2.input.from).toBe("b");
|
|
}, 30_000);
|
|
});
|