From b1b2cfa013aa18c680d1c392049fca899303914b Mon Sep 17 00:00:00 2001 From: Michael Roberts Date: Tue, 13 Oct 2026 12:49:33 +0100 Subject: [PATCH] chore(fixtures): add a test Workflow and pin the Workflow binding's behaviour in @observerly/orderly --- .changeset/add-workflow-fixture.md | 2 + tests/env.d.ts | 3 + tests/fixtures/echo.ts | 50 +++++++++ tests/fixtures/settled.ts | 42 ++++++++ tests/fixtures/worker.ts | 8 ++ tests/workflow-binding.spec.ts | 166 +++++++++++++++++++++++++++++ wrangler.jsonc | 11 ++ 7 files changed, 282 insertions(+) create mode 100644 .changeset/add-workflow-fixture.md create mode 100644 tests/fixtures/echo.ts create mode 100644 tests/fixtures/settled.ts create mode 100644 tests/workflow-binding.spec.ts diff --git a/.changeset/add-workflow-fixture.md b/.changeset/add-workflow-fixture.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/add-workflow-fixture.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/tests/env.d.ts b/tests/env.d.ts index c0b7f80..c70079a 100644 --- a/tests/env.d.ts +++ b/tests/env.d.ts @@ -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; + // 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; } } diff --git a/tests/fixtures/echo.ts b/tests/fixtures/echo.ts new file mode 100644 index 0000000..5537e21 --- /dev/null +++ b/tests/fixtures/echo.ts @@ -0,0 +1,50 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @package @observerly/orderly +// @license Copyright © 2026 observerly + +/*****************************************************************************************************************/ + +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from 'cloudflare:workers'; + +/*****************************************************************************************************************/ + +export type EchoParams = Record; + +/*****************************************************************************************************************/ + +// 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; +} + +/*****************************************************************************************************************/ + +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 { + 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>, + // oxlint-disable-next-line typescript/prefer-readonly-parameter-types + step: WorkflowStep, + ): Promise { + const { payload } = event; + + return step.do('echo', () => + Promise.resolve({ + seen: Object.fromEntries(Object.keys(payload).map(key => [key, kindOf(payload[key])])), + }), + ); + } +} + +/*****************************************************************************************************************/ diff --git a/tests/fixtures/settled.ts b/tests/fixtures/settled.ts new file mode 100644 index 0000000..b5d38c1 --- /dev/null +++ b/tests/fixtures/settled.ts @@ -0,0 +1,42 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @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 => { + 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); +}; + +/*****************************************************************************************************************/ diff --git a/tests/fixtures/worker.ts b/tests/fixtures/worker.ts index b5f6fe6..1c33b20 100644 --- a/tests/fixtures/worker.ts +++ b/tests/fixtures/worker.ts @@ -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'; /*****************************************************************************************************************/ diff --git a/tests/workflow-binding.spec.ts b/tests/workflow-binding.spec.ts new file mode 100644 index 0000000..b730d3c --- /dev/null +++ b/tests/workflow-binding.spec.ts @@ -0,0 +1,166 @@ +/*****************************************************************************************************************/ + +// @author Michael Roberts +// @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>(); + }); + + it('accepts a binding whose params are wider', () => { + expectTypeOf>().toExtend>(); + + expectTypeOf>().toExtend>(); + }); + + it('refuses a binding whose params are unrelated', () => { + expectTypeOf>().not.toExtend>(); + + expectTypeOf>().not.toExtend>(); + }); +}); + +/*****************************************************************************************************************/ diff --git a/wrangler.jsonc b/wrangler.jsonc index 869cd64..4fc3e5b 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -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", + }, + ], }