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
534 lines
15 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
}
|
|
);
|