Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/add-workflow-fixture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions tests/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ declare namespace Cloudflare {
// the top of the file: a top level import would make this a module, and the
// Cloudflare namespace augmentation would stop being global.
RUN: DurableObjectNamespace<import('./fixtures/worker').Run>;
// Left with unknown params, exactly as `wrangler types` writes a Workflow
// binding, so the harness meets the same binding type a project would.
ECHO: Workflow;
}
}

Expand Down
50 changes: 50 additions & 0 deletions tests/fixtures/echo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @package @observerly/orderly
// @license Copyright © 2026 observerly

/*****************************************************************************************************************/

import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers';

/*****************************************************************************************************************/

export type EchoParams = Record<string, unknown>;

/*****************************************************************************************************************/

// What the Workflow saw of each param: 'Date' for a Date, its typeof otherwise. A Date is named apart because
// typeof reads every object alike, and whether a Date survived the crossing intact is the fact worth reading.
export interface EchoOutput {
seen: Record<string, string>;
}

/*****************************************************************************************************************/

const kindOf = (value: unknown): string => (value instanceof Date ? 'Date' : typeof value);

/*****************************************************************************************************************/

// A Workflow that reports what reached it and does nothing else, so a test can read from the instance's output
// exactly which params it was created with, and in what form they crossed.
export class Echo extends WorkflowEntrypoint<Cloudflare.Env, EchoParams> {
override run(
// The event and the step are the platform's own types, read and never written, and neither can be made
// readonly here without departing from the signature WorkflowEntrypoint declares.
// oxlint-disable-next-line typescript/prefer-readonly-parameter-types
event: Readonly<WorkflowEvent<EchoParams>>,
// oxlint-disable-next-line typescript/prefer-readonly-parameter-types
step: WorkflowStep,
): Promise<EchoOutput> {
const { payload } = event;

return step.do('echo', () =>
Promise.resolve({
seen: Object.fromEntries(Object.keys(payload).map(key => [key, kindOf(payload[key])])),
}),
);
}
}

/*****************************************************************************************************************/
42 changes: 42 additions & 0 deletions tests/fixtures/settled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @package @observerly/orderly
// @license Copyright © 2026 observerly

/*****************************************************************************************************************/

// How long to wait between reads of an instance's status, and how many reads to make before giving up.
const INTERVAL_IN_MILLISECONDS = 20;

const READS = 250;

/*****************************************************************************************************************/

// Follows an instance until it settles, reading its status until it is complete or errored, and resolves with
// that status. Status is read rather than introspected, because the introspection the pool offers floods the
// log with the engine's own noise. Recursive rather than a loop, so each read waits on the one before it.
export const settled = async (
// A handle the platform owns, read and never written: its methods are what make it a mutable type.
// oxlint-disable-next-line typescript/prefer-readonly-parameter-types
instance: WorkflowInstance,
reads = READS,
): Promise<InstanceStatus> => {
const status = await instance.status();

if (status.status === 'complete' || status.status === 'errored') {
return status;
}

if (reads <= 1) {
throw new Error(`instance ${instance.id} had not settled, reading ${status.status}`);
}

await new Promise(resolve => {
setTimeout(resolve, INTERVAL_IN_MILLISECONDS);
});

return settled(instance, reads - 1);
};

/*****************************************************************************************************************/
8 changes: 8 additions & 0 deletions tests/fixtures/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import { DurableObject } from 'cloudflare:workers';

/*****************************************************************************************************************/

// The runtime reads Workflow classes from the main module, as it does Durable Objects, so the test Workflow is
// re-exported here from the file that defines it.
export { Echo } from './echo';

export type { EchoOutput, EchoParams } from './echo';

/*****************************************************************************************************************/

export type StepEvent = 'started' | 'succeeded' | 'failed';

/*****************************************************************************************************************/
Expand Down
166 changes: 166 additions & 0 deletions tests/workflow-binding.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*****************************************************************************************************************/

// @author Michael Roberts <michael@observerly.com>
// @package @observerly/orderly
// @license Copyright © 2026 observerly

/*****************************************************************************************************************/

import { env } from 'cloudflare:workers';

import { describe, expect, expectTypeOf, it } from 'vitest';

import { settled } from './fixtures/settled';

/*****************************************************************************************************************/

// What these specs pin is the real binding, as the local engine runs it, rather than anything orderly does: the
// facts the Workflows module is built on, so a change in the engine shows here first, by name.

/*****************************************************************************************************************/

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;

/*****************************************************************************************************************/

describe('the Workflow binding', () => {
it('creates an instance and runs it to completion', async () => {
const instance = await env.ECHO.create({ params: { observatory: 'greenwich' } });

await expect(settled(instance)).resolves.toMatchObject({
status: 'complete',
output: { seen: { observatory: 'string' } },
});
});

it('mints a version 4 UUID for an instance created without an id', async () => {
const instance = await env.ECHO.create({ params: {} });

expect(instance.id).toMatch(UUID);
});

it('carries a Date into the instance as a Date, by structured clone', async () => {
const instance = await env.ECHO.create({ params: { at: new Date(), count: 1 } });

await expect(settled(instance)).resolves.toMatchObject({
output: { seen: { at: 'Date', count: 'number' } },
});
});
});

/*****************************************************************************************************************/

describe('the Workflow binding reading instance ids', () => {
it.each([
['a single letter', 'a'],
['a leading underscore', '_a'],
['a trailing hyphen', 'a-'],
['letters, digits, hyphens and underscores', 'Greenwich_2026-01-15'],
['exactly 100 characters', 'x'.repeat(100)],
['a UUID', '638b5fca-be45-4d6c-a699-3d93974a227e'],
])('accepts %s', async (_, id) => {
const instance = await env.ECHO.create({ id, params: {} });

expect(instance.id).toBe(id);
});

it.each([
['the empty string', ''],
['a leading hyphen', '-a'],
['a colon', 'greenwich:sunrise'],
['a space', 'greenwich sunrise'],
['a dot', 'greenwich.sunrise'],
['a letter outside ASCII', 'é'],
['101 characters', 'x'.repeat(101)],
])('refuses %s', async (_, id) => {
await expect(env.ECHO.create({ id, params: {} })).rejects.toThrow(
'Workflow instance has invalid id',
);
});
});

/*****************************************************************************************************************/

describe('the Workflow binding finding instances', () => {
it('finds an instance by its id', async () => {
await env.ECHO.create({ id: 'found', params: {} });

const instance = await env.ECHO.get('found');

expect(instance.id).toBe('found');
});

it('refuses an id with no instance behind it', async () => {
await expect(env.ECHO.get('missing')).rejects.toThrow('instance.not_found');
});
});

/*****************************************************************************************************************/

// The platform's types say a create by an id that exists throws. The local engine does not: it starts nothing and
// hands back the instance already there, which keeps the params it was first created with.
describe('the Workflow binding creating by an id that exists', () => {
it('starts nothing while the first instance is still running', async () => {
const first = await env.ECHO.create({ id: 'running', params: { first: 1 } });

expect((await first.status()).status).toBe('running');

const second = await env.ECHO.create({ id: 'running', params: { second: 'two' } });

expect(second.id).toBe('running');

await expect(settled(first)).resolves.toMatchObject({ output: { seen: { first: 'number' } } });
});

it('starts nothing once the first instance has settled', async () => {
await settled(await env.ECHO.create({ id: 'settled', params: { first: 1 } }));

const second = await env.ECHO.create({ id: 'settled', params: { second: 'two' } });

await expect(settled(second)).resolves.toMatchObject({
status: 'complete',
output: { seen: { first: 'number' } },
});
});

it('starts one instance for an id repeated within a batch, keeping the first params', async () => {
const instances = await env.ECHO.createBatch([
{ id: 'batched', params: { first: 1 } },
{ id: 'batched', params: { second: 'two' } },
]);

expect(instances).toMatchObject([{ id: 'batched' }, { id: 'batched' }]);

await expect(settled(await env.ECHO.get('batched'))).resolves.toMatchObject({
output: { seen: { first: 'number' } },
});
});
});

/*****************************************************************************************************************/

type Closedown = { observatory: string };

/*****************************************************************************************************************/

// A binding's methods are checked bivariantly, so a binding is accepted where its params are unknown, as
// `wrangler types` writes them, or wider than a schema's, and refused only when they are unrelated to it.
describe('the Workflow binding as a type', () => {
it('accepts a binding with unknown params where a typed one is wanted', () => {
expectTypeOf(env.ECHO).toExtend<Workflow<Closedown>>();
});

it('accepts a binding whose params are wider', () => {
expectTypeOf<Workflow<Closedown | null>>().toExtend<Workflow<Closedown>>();

expectTypeOf<Workflow<object>>().toExtend<Workflow<Closedown>>();
});

it('refuses a binding whose params are unrelated', () => {
expectTypeOf<Workflow<{ telescope: number }>>().not.toExtend<Workflow<Closedown>>();

expectTypeOf<Workflow<string>>().not.toExtend<Workflow<Closedown>>();
});
});

/*****************************************************************************************************************/
11 changes: 11 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,15 @@
},
],
},
// A Workflow that reports the params it was created with, so tests can
// pin how the real binding behaves: which ids it accepts, what a repeated
// id does, and how params cross into an instance. Like the Durable Object
// above, its class is read from the main module.
"workflows": [
{
"name": "echo",
"binding": "ECHO",
"class_name": "Echo",
},
],
}
Loading