Files
wehub-resource-sync 70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:11 +08:00

534 lines
15 KiB
TypeScript

import dedent from "ts-dedent";
import { afterAll, describe, it } from "vitest";
import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id";
import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test";
import { generateResourceName } from "./helpers/generate-resource-name";
const TIMEOUT = 60_000;
describe.skipIf(!CLOUDFLARE_ACCOUNT_ID)(
"durable-objects-exports",
{ timeout: TIMEOUT },
() => {
describe("wrangler deploy", () => {
const workerName = generateResourceName();
const helper = new WranglerE2ETestHelper();
afterAll(async () => {
await helper.bestEffortRun(`wrangler delete`);
});
it("scenario 1: auto-provisions a new namespace on first deploy", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "DO", "class_name": "MyDO" }],
},
"exports": {
"MyDO": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class MyDO extends DurableObject {}
export default {
fetch() { return new Response("hello"); },
};
`,
"package.json": dedent`
{
"name": "${workerName}",
"version": "0.0.0",
"private": true
}
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain(
"Durable Object exports reconciliation"
);
expect(output.stdout).toContain("Created: MyDO");
});
it("scenario 2: re-deploying with the same config is a no-op", async ({
expect,
}) => {
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).not.toContain("Created: ");
expect(output.stdout).not.toContain("Deleted: ");
});
it("scenario T2: processes a deleted tombstone after removing the class from code", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"exports": {
"MyDO": { "type": "durable-object", "state": "deleted" },
},
}
`,
"src/index.ts": dedent`
export default {
fetch() { return new Response("hello"); },
};
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain("Deleted: MyDO");
});
it("scenario T3: emits a stale-tombstone info and a removable_entries hint", async ({
expect,
}) => {
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain("[stale_tombstone] MyDO");
expect(output.stdout).toContain("Safe to remove from `exports`: MyDO");
});
it("scenario 5: rejects exports that declare a class not present in code", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"exports": {
"Phantom": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
export default {
fetch() { return new Response("hello"); },
};
`,
});
const result = await helper.run(`wrangler deploy`);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
"Durable Object exports reconciliation failed"
);
expect(result.stderr).toContain(
"[config_references_nonexistent_class]"
);
});
});
describe("zero-downtime rename", () => {
const workerName = generateResourceName();
const helper = new WranglerE2ETestHelper();
afterAll(async () => {
await helper.bestEffortRun(`wrangler delete`);
});
it("step 1: creates the original Counter class", async ({ expect }) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "DO", "class_name": "Counter" }],
},
"exports": {
"Counter": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject {}
export default {
fetch() { return new Response("counter"); },
};
`,
"package.json": dedent`
{
"name": "${workerName}",
"version": "0.0.0",
"private": true
}
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain("Created: Counter");
});
it("step 2: renames Counter → CounterV2 in a single deploy", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "DO", "class_name": "CounterV2" }],
},
"exports": {
"CounterV2": { "type": "durable-object", "storage": "sqlite" },
"Counter": {
"type": "durable-object",
"state": "renamed",
"renamed_to": "CounterV2",
},
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class CounterV2 extends DurableObject {}
export default {
fetch() { return new Response("counter v2"); },
};
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain("Renamed: Counter → CounterV2");
expect(output.stdout).not.toContain("Created: CounterV2");
});
it("step 3: dropping the rename tombstone is a no-op", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "DO", "class_name": "CounterV2" }],
},
"exports": {
"CounterV2": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class CounterV2 extends DurableObject {}
export default {
fetch() { return new Response("counter v2"); },
};
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).not.toContain("Renamed: ");
expect(output.stdout).not.toContain("Created: ");
});
});
describe("cross-script transfer", () => {
const workerA = generateResourceName();
const workerB = generateResourceName();
const helperA = new WranglerE2ETestHelper();
const helperB = new WranglerE2ETestHelper();
afterAll(async () => {
await helperA.bestEffortRun(`wrangler delete`);
await helperB.bestEffortRun(`wrangler delete`);
});
it("step 1: source script provisions Widget", async ({ expect }) => {
await helperA.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerA}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "WIDGET", "class_name": "Widget" }],
},
"exports": {
"Widget": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class Widget extends DurableObject {}
export default {
fetch() { return new Response("source"); },
};
`,
"package.json": dedent`
{
"name": "${workerA}",
"version": "0.0.0",
"private": true
}
`,
});
const output = await helperA.run(`wrangler deploy`);
expect(output.stdout).toContain("Created: Widget");
});
it("step 2: target script deploys expecting-transfer → Transfer pending", async ({
expect,
}) => {
// The target can add the binding after the transfer commits.
await helperB.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerB}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"exports": {
"Widget": {
"type": "durable-object",
"state": "expecting-transfer",
"storage": "sqlite",
"transfer_from": "${workerA}",
},
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class Widget extends DurableObject {}
export default {
fetch() { return new Response("target"); },
};
`,
"package.json": dedent`
{
"name": "${workerB}",
"version": "0.0.0",
"private": true
}
`,
});
const output = await helperB.run(`wrangler deploy`);
expect(output.stdout).toContain(
`Transfer pending: Widget ← ${workerA}`
);
});
it("step 3: source script commits the tombstone → Transferred (committed)", async ({
expect,
}) => {
await helperA.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerA}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"exports": {
"Widget": {
"type": "durable-object",
"state": "transferred",
"transferred_to": "${workerB}",
},
},
}
`,
"src/index.ts": dedent`
export default {
fetch() { return new Response("source after transfer"); },
};
`,
});
const output = await helperA.run(`wrangler deploy`);
expect(output.stdout).toContain(
`Transferred (committed): Widget → ${workerB}`
);
});
it("step 4: target script adds its `Widget` binding now that the transfer has committed", async ({
expect,
}) => {
// The namespace now lives on workerB, so this deploy can add the binding.
await helperB.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerB}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "WIDGET", "class_name": "Widget" }],
},
"exports": {
"Widget": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class Widget extends DurableObject {}
export default {
fetch() { return new Response("target with binding"); },
};
`,
});
const output = await helperB.run(`wrangler deploy`);
expect(output.stdout).toContain("env.WIDGET (Widget)");
expect(output.stdout).not.toContain("Created: ");
expect(output.stdout).not.toContain("Transfer pending: ");
expect(output.stdout).not.toContain("Transferred (committed): ");
expect(output.stdout).not.toContain("Deleted: ");
expect(output.stdout).not.toContain("Renamed: ");
});
});
describe("wrangler versions upload", () => {
const workerName = generateResourceName();
const helper = new WranglerE2ETestHelper();
let versionId: string;
afterAll(async () => {
await helper.bestEffortRun(`wrangler delete`);
});
it("step 1: bootstrap deploys SomeClass via `exports`", async ({
expect,
}) => {
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [{ "name": "DO", "class_name": "SomeClass" }],
},
"exports": {
"SomeClass": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class SomeClass extends DurableObject {}
export default {
fetch() { return new Response("hello"); },
};
`,
"package.json": dedent`
{
"name": "${workerName}",
"version": "0.0.0",
"private": true
}
`,
});
const output = await helper.run(`wrangler deploy`);
expect(output.stdout).toContain("Created: SomeClass");
});
// Mirrors the realistic customer flow: stage a new class via
// `exports` (reached through `ctx.exports`, with NO binding to it
// yet) on `versions upload`, then add the binding when the version
// is deployed (step 3). EWC reconciles declarative `exports` at
// deploy time, so an upload that merely declares a new class — and
// doesn't bind to it — succeeds without running reconciliation.
// (A binding to a class declared only in `exports` is instead
// rejected at upload with code 100406; that path is covered by the
// versions.upload.test.ts unit tests.)
it("step 2: `versions upload` stages a new class without running reconciliation", async ({
expect,
}) => {
// Introduce AnotherClass via a draft version, accessed through
// `ctx.exports.AnotherClass` (no binding). EWC persists
// `exports` on the new version with reconciliation deferred to
// deploy, so the reconciliation envelope is NOT emitted at
// upload time — it runs when the version is deployed (step 3).
await helper.seed({
"wrangler.jsonc": dedent`
{
"name": "${workerName}",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"durable_objects": {
"bindings": [
{ "name": "DO", "class_name": "SomeClass" },
],
},
"exports": {
"SomeClass": { "type": "durable-object", "storage": "sqlite" },
"AnotherClass": { "type": "durable-object", "storage": "sqlite" },
},
}
`,
"src/index.ts": dedent`
import { DurableObject } from "cloudflare:workers";
export class SomeClass extends DurableObject {}
// AnotherClass is declared in \`exports\` only (no binding).
// It becomes reachable via \`ctx.exports.AnotherClass\` once
// the version is deployed; a binding can be added then.
export class AnotherClass extends DurableObject {}
export default {
fetch() { return new Response("hello"); },
};
`,
});
const output = await helper.run(`wrangler versions upload`);
expect(output.stdout).toContain("Worker Version ID:");
expect(output.stdout).not.toContain(
"Durable Object exports reconciliation"
);
expect(output.stdout).not.toContain("Created: AnotherClass");
versionId = output.stdout.match(
/Worker Version ID:\s+([a-f\d-]+)/
)?.[1] as string;
expect(versionId).toBeTruthy();
});
// Step 3 depends on the versionId captured in step 2.
it("step 3: `versions deploy` runs the deferred reconciliation", async ({
expect,
}) => {
const output = await helper.run(
`wrangler versions deploy ${versionId}@100% --yes`
);
expect(output.stdout).toContain("SUCCESS");
});
});
}
);