Testing a plugin
This guide explains what to prove before using a plugin with real content, how to test it against a fake core in your own suite, how to run both services on one machine, and how to produce each failure deliberately.
See Protocol v1 for the surface under test and Plugin security for the invariants the cases below exist to protect.
The contract
Section titled “The contract”These are the test cases a plugin must cover. Each one is a property of the protocol rather than of any particular plugin, so each one belongs in your suite whatever your plugin does.
| Case | What must happen |
|---|---|
| Activation | A one-time code is consumed exactly once. An unknown, used or expired code is refused. The raw token is stored encrypted and never logged. The handler answers well inside 5 seconds and makes no protocol call of its own |
| Rotation | kind: rotate replaces the stored token. The old one stops being used by every worker. A refused rotation leaves the old token in place |
| Launch | A code is exchanged once, through the plugin’s backend. A second exchange fails. A code minted for another installation fails. The management page sends Referrer-Policy: no-referrer and drops dee_wan_launch from the URL after the exchange |
| Session | The plugin’s own browser session is created at exchange, is its own cookie, and carries no Dee Wan credential |
| Pinning | Publish pins current_version_id, unpublish pins published_version_id, both pin workflow.revision, and the chosen (kind, to) came from the delegated read |
| Idempotency | The key is derived from the record and its revision, is identical on every retry, and changes when the record is rescheduled |
| At most once | Only the current lease owner may start a delivery. Its request deadline must precede lease expiry. Recovery may repeat the request with the same key and body. Core must apply the effect at most once within retention. A timeout does not prove a remote request stopped |
| Replay | idempotent_replay settles as success, not as a new effect |
| Terminal refusals | invalid_request, plugin_unauthorized, capability_refused, requester_unauthorized, content_not_found, scheduled_target_stale, workflow_conflict, dependent_content, idempotency_key_reused, transition_unavailable and transition_refused each settle without a retry, under their own name |
| Retries | Valid structured rate_limited/429 and temporary_failure/503, bare 429/500/502/503/504 without an outcome, and network/timeouts retry within attempt AND age caps. Honour valid bounded Retry-After. Otherwise back off with jitter |
| Untrusted responses | A present but malformed outcome, unknown code, incorrect retry flag or status/code mismatch is terminal even on a retryable status. A body without an outcome is terminal outside the listed fallback statuses. Redirects are never followed |
| Isolation | Every table and every query is scoped by installation. One installation cannot read, cancel or execute another’s records |
| Revocation | After uninstall, pending work refuses with plugin_unauthorized and the plugin says so rather than retrying |
| Secrets | No test fixture, log line, error body or screenshot contains a token, a launch code or an activation code |
| Writes for a person | A field write, draft, media upload or proposal names the delegation of the person it is for, pins the version or draft revision it read, carries an Idempotency-Key, and treats version_conflict and draft_conflict as a person’s decision, not a retry |
The Scheduling plugin source (plugins/scheduling in the Dee Wan repository) has a suite that is a
worked example of every row, in test/activation.test.ts, launch.test.ts, schedules.test.ts,
runner.test.ts, recovery.test.ts, time.test.ts, isolation.test.ts and flow.test.ts. Its
test/conformance.test.ts runs the protocol cases of @dee-wan/plugin-conformance against it.
Prove it against a fake core
Section titled “Prove it against a fake core”Do not point your suite at a running Dee Wan. A fake core is deterministic, can inject failures a real one will not produce on demand, and runs in milliseconds.
@dee-wan/plugin-conformance is the installable protocol v1 suite. Its source is
packages/plugin-conformance in the Dee Wan repository, where it is built and tested. It is NOT
published to the npm registry yet; publishing it needs authority. Until it is, a plugin in another
repository builds it from a Dee Wan checkout and installs the tarball:
cd packages/plugin-conformance # in a Dee Wan checkoutnpm installnpm pack # writes dee-wan-plugin-conformance-0.1.0.tgzcd /path/to/your-pluginnpm install --save-dev /path/to/dee-wan-plugin-conformance-0.1.0.tgzIt carries the published schemas, so it reads nothing from a Dee Wan checkout at run time. It exports:
| Export | What it is |
|---|---|
createFakeCore() |
A fetch implementation that plays Dee Wan core for one or more installations |
runConformance({ describe, it }, subject) |
Registers the protocol cases with vitest, node:test or jest |
conformanceCases(subject) |
The same cases as plain async functions |
validateManifest(value), validateShape(name, value) |
Validation against the published manifest schema and the protocol $defs |
OUTCOMES, CAPABILITIES, TRANSITION_KINDS, PROTOCOL_VERSIONS, INBOUND_RETRY_STATUSES, SECRET_PATTERNS |
The published protocol data |
The fake core:
- authenticates a bearer token per installation, and answers
plugin_unauthorizedotherwise - answers
/context,/content/:idand/launch/exchangewith protocol-shaped bodies, including theworkflowblock and the transitions the installation’s capabilities and a delegation permit - applies the installation’s capability and model grant, and refuses a delegation that does not cover the item, the way core does
- refuses a command body that is not the published
transition_commandwithinvalid_request - checks the pin and the workflow revision on a command, and answers
scheduled_target_staleorworkflow_conflictwhen they no longer hold, anddependent_contentfor an item marked with dependents - keeps an idempotency record per key, so an exact replay answers
idempotent_replaywith the whole first result and a reused key with different input answersidempotency_key_reused - reports
redirecton every publish result, from the item’sredirectfield, and none on unpublish - moves one language when a command names
localeand the installation holdsworkflow:locale, and lists each language underworkflow.localesfor that installation - consumes a launch code once and refuses it for another installation
- mints invocation envelopes with
createInvocation, and consumes an invocation code once at/invocations/exchange, for the installation it was minted for, before it expires - answers the write routes —
PUT /content/:id,PUT /drafts,POST /drafts/discard,GET /drafts,POST /media,POSTandGET /content/:id/proposals,GET /proposals/:id— with the published shapes, the capability, grant and delegation checks, version and draft-revision pins and per-key replay; field values replace the item’s version, a draft belongs to the delegated person, and a proposal changes nothing - pins a one-language publish to the group’s current version and a one-language unpublish to that language’s own live version, as core does
- injects failures on demand — a network error, a reply lost after the command was applied, any
status, any
outcome.code, aRetry-Afterheader, or a deliberately malformed body - records requests separately from applied effects, so recovery can resend the same key while the effect occurs at most once.
It does not answer the paginated /content list.
runConformance takes a subject: your manifest, your copy of the protocol data, your reply
classifier, and optional adapters for activation, launch exchange, delivery, operation invocation
(required when the manifest declares operations) and media source invocation (mediaSource,
required when it declares media_sources). A plugin that sends no commands, such as a media source,
leaves out the protocol copy and the classifier. Each adapter starts a
fresh instance of your plugin wired to a fake core. A minimal vitest file:
import { runConformance } from '@dee-wan/plugin-conformance';import { describe, it } from 'vitest';import { subject } from './conformance-subject';
runConformance({ describe, it }, subject);The cases: your outcome table, retry statuses, capabilities, transition kinds, protocol version and
secret shapes equal the published ones; your manifest validates; every outcome code, wrong status,
wrong retry flag, malformed outcome and bare status classifies as the protocol says; activation
codes are single-use and rotation codes are bound to their installation; a launch code is exchanged
once; and through your own delivery path, a pinned command is sent once, a lost reply is resent
with the same key and body and applied once, every terminal outcome settles under its own name and
is never sent again, retryable outcomes retry, and a stale pin, a changed workflow, dependents, a
person who lost the permission and an uninstall each refuse. For each declared operation, the
plugin answers only after exchanging the code once, with a reply the published schema accepts and
only declared result components, and never answers completed for a refused code, a code minted
for another installation or an envelope with an unknown key. For each declared content-list column,
the exchange binds no delegation and the plugin answers one cells block for the rows it was asked
about only, each row once, each cell drawn as the declared cell kind (a url on a link and on no
other, a tone on a badge and on no other). For each declared media source, a
browse is answered only after the exchange, with every preview and download URL on the manifest’s
origin and every delivery URL on a declared delivery origin; the declaration names its modes and
has delivery_origins exactly when it declares reference; its own next_cursor leads to a page that repeats no item; an item it listed is answered as
that item; and it never answers completed for a refused code or another installation’s code. The
Scheduling plugin’s test/conformance.test.ts is a complete subject;
plugins/media-source-reference/test/conformance.test.ts is one for a media source.
The fake core is not the real core, and it supports only a two-state draft/published workflow.
Assert on outcome.code and HTTP status, never on the human message — core’s messages are for
people and are not stable.
The Scheduling plugin’s test/helpers/d1.ts is a short better-sqlite3 shim with D1’s
prepare/bind/first/all/run/batch surface, so the plugin’s real SQL — including its
compare-and-set claims and its partial unique index — runs in the suite. It applies every file in the
plugin’s migrations/. Copy it and point it at your own migrations.
To run the reference plugin’s suite:
cd plugins/schedulingnpm test # vitestnpm run typecheckInside the Dee Wan repository its tools resolve from the repository root’s install
(node scripts/install-dependencies.mjs at the root), and @dee-wan/plugin-conformance is linked
from packages/plugin-conformance: run npm run build there first.
Validate against the published schemas
Section titled “Validate against the published schemas”Two JSON Schema documents are generated from the definitions core validates with, and published with each release:
schemas/manifest-v1.schema.json— validate your manifest in your own CI, before an administrator ever fetches it.schemas/protocol-v1.schema.json— request and response shapes under$defs, plus the machine-readableoutcomestable (HTTP status and retry flag per code),capabilities,installation_statesandlimits.
The outcomes table is the one worth wiring in: a plugin that keeps its own copy — the reference
plugin does, in src/protocol.ts, because it cannot import from core — should assert that its copy
equals the published one, so a status or retry flag that drifts fails your build rather than your
production runs. The protocol copy cases of runConformance do exactly that.
A local two-service setup
Section titled “A local two-service setup”Both services on one machine, with no Cloudflare account and no real credential. This is the one local setup for plugin work. Scheduling, end to end starts from it.
You need the Node version pinned by .nvmrc in the Dee Wan repository, openssl, and these ports:
8787 (Dee Wan’s admin Worker), 5173 (the admin UI) and 8788 (the plugin).
Port 8788 is also the default of npm run local:public, Dee Wan’s public read Worker. If you
run that too, give one of them another port: npm run local:public -- --port 8789, or run the
plugin on another port and use that port everywhere 8788 appears below.
Dee Wan
Section titled “Dee Wan”In backend/.dev.vars in the Dee Wan repository (create it if it does not exist), add the plugin’s
origin:
PLUGIN_DEV_ORIGINS=http://localhost:8788That is the only setting you add. The local admin Worker’s configuration already sets
ENVIRONMENT=development, which is what lets PLUGIN_DEV_ORIGINS be read at all, and
BACKEND_URL=http://localhost:8787, which is what core sends to your plugin as dee_wan_base_url.
PLUGIN_DEV_ORIGINS is an exact-origin allowlist — no wildcard, no private-range shortcut — checked
against the manifest, base, activation and management URLs. Without the entry, installing from
http://localhost:8788 is refused as url_not_https.
Then, from the repository root, each long-running command in its own terminal:
node scripts/install-dependencies.mjsnode scripts/install-dependencies.mjs frontendnpm run local:migratenpm run local:api # admin Worker on http://localhost:8787 (leave running)npm run local:seed # needs local:api running; prints the sign-in it createsnpm run local:admin # admin UI on http://localhost:5173 (leave running)The plugin
Section titled “The plugin”For the reference plugin, from the repository root:
cd plugins/schedulingcp wrangler.example.jsonc wrangler.jsoncThe placeholder database_id in the copied file is fine: --local never contacts Cloudflare, so
there is no wrangler d1 create step. Next to it, create .dev.vars:
ENVIRONMENT=developmentPUBLIC_ORIGIN=http://localhost:8788DEE_WAN_DEV_ORIGINS=http://localhost:8787ADMIN_SECRET=local-admin-secret-0123456789TOKEN_KEY=<the output of: openssl rand -base64 32>.dev.varsoverrides the production values inwrangler.jsoncforwrangler dev.DEE_WAN_DEV_ORIGINSis the plugin’s mirror image ofPLUGIN_DEV_ORIGINS: the one non-HTTPS Dee Wan base URL it accepts, and only whenENVIRONMENT=development.ADMIN_SECRETis the operator bearer for the plugin’s/admin/*routes, and must be at least 16 characters.TOKEN_KEYis the base64 of 32 random bytes and encrypts stored installation tokens at rest. Lose it and every installation has to be rotated.
npx wrangler d1 migrations apply dee-wan-scheduling --localnpx wrangler dev --port 8788 --test-scheduled # leave runningcurl http://localhost:8788/dee-wan/manifest.json # the manifest names http://localhost:8788Mint a one-time activation code:
curl -X POST -H "Authorization: Bearer local-admin-secret-0123456789" \ http://localhost:8788/admin/activation-codesIt answers 201 with { "code", "purpose", "installation_id", "expires_at" }: purpose is
activate, installation_id is null, and expires_at is 15 minutes ahead, in milliseconds since
the epoch.
A plugin of your own needs the same three things: serve it on the origin you allowed, accept
http://localhost:8787 as a base URL only in development, and have a way to mint an activation
code.
Install
Section titled “Install”Sign in to the admin UI at http://localhost:5173 with the account local:seed printed, then
Settings → Plugins: review http://localhost:8788/dee-wan/manifest.json, grant a capability set,
pick the models, and install with the code. Open a content item of a granted model and use the
action your manifest declared.
Do the installation through the UI rather than by hand: the administrative routes want a real
session, the site header and site:plugins, and the UI already has all three.
Two local gotchas:
- A
__Host-/Securesession cookie needs HTTPS orlocalhostexactly — not127.0.0.1. - A cron trigger does not fire in
wrangler devunless you ask it to. With--test-scheduled,curl "http://localhost:8788/__scheduled?cron=*+*+*+*+*"runs one tick.
The automated cross-service run
Section titled “The automated cross-service run”The reference plugin and core run against each other, both live, in one command. With the Node
version .nvmrc pins, prepare a Dee Wan checkout, then run the plugin’s script:
node scripts/install-dependencies.mjsnpm run build:packagesnpm --workspace backend run prisma:generatecd plugins/schedulingnpm run test:cross-serviceThe test lives in the plugin, at test/cross-service/cross-service.test.mjs. Outside the Dee Wan
repository, set DEE_WAN_CORE_DIR to the prepared Dee Wan checkout. It needs no Cloudflare
account, no credential and no network beyond 127.0.0.1. It takes two to three minutes, because it
schedules for a real wall-clock minute and waits for it.
What it starts, each on a free port it picks, and stops again, with its temporary directory:
- Dee Wan’s admin Worker under
wrangler dev, on a fresh migrated local D1, withPLUGIN_DEV_ORIGINSnaming the plugin. - the Scheduling plugin under
wrangler dev --test-scheduled, from a generated copy ofwrangler.example.jsonc, on a fresh migrated local D1. - an HTTP proxy in the test process. Core’s
BACKEND_URLnames it, so every plugin call to core passes through it. It forwards requests and replies unchanged, apart from theHostheader. It can drop a request before forwarding it, or drop the reply after core answered, and it records each transition command it saw. The proxy intercepts network traffic; every answer comes from core.
Everything goes through the real routes: the bootstrap claim and sign-in, the /api/plugins calls
Settings → Plugins makes, delegated launches, the plugin’s own HTML forms, and /__scheduled ticks.
It checks:
| Case | What it asserts |
|---|---|
| Activation | Install through core activates the plugin. Reusing the activation code is refused activation_refused |
| Delegated launch | Each schedule is created through POST /api/plugins/:id/launch, the plugin’s exchange and its form. The confirmation shows the local time, its zone and the UTC instant |
| Before due | A tick before the due minute sends nothing |
| Publish, reply lost | Core applies the publish, the proxy drops the reply, the plugin retries with the same key and byte-identical body, and core answers idempotent_replay with the same result data as the dropped reply, redirect included. One plugin workflow event, attributed to the installation and the delegating person |
| Publish, request lost | The proxy drops the first request unforwarded. The plugin retries and core answers transition_applied |
| Unpublish | The pinned published version is unpublished |
scheduled_target_stale |
The draft was edited after pinning |
workflow_conflict |
The item was published and unpublished by hand after pinning |
transition_unavailable |
The item was published by hand after a publish was pinned |
dependent_content |
A published item relates to the one being unpublished |
| Cancellation | Cancelled in the plugin UI. Core is never called |
| Isolation | One installation’s session cannot read or cancel another installation’s schedule |
| Revocation | Core uninstalls an installation with a pending schedule. The plugin settles it plugin_unauthorized |
| Settled stays settled | Every refused or cancelled case leaves core’s state, revision and publication pointer unchanged, and a later tick sends nothing |
Dee Wan’s CI does not run it. The plugin’s own CI, .github/workflows/ci.yml in the plugin
directory, runs it against a Dee Wan checkout once the plugin is in its own repository. Until then,
run it by hand when a change touches either side of protocol v1.
The browser run with the plugin live
Section titled “The browser run with the plugin live”frontend/cypress/e2e/real-scheduling-releases.cy.ts drives the plugin’s release pages in a browser,
against the real admin Worker and the real plugin. The e2e runner (frontend/scripts/run-e2e.mjs)
starts the plugin for a run with E2E_REAL_BACKEND=1 that can reach that spec: the whole suite, or a
--spec that matches it. E2E_SCHEDULING_PLUGIN=1 forces it on and 0 forces it off.
E2E_REAL_BACKEND=1 npm --prefix frontend run test:e2e -- --spec cypress/e2e/real-scheduling-releases.cy.tsWhat the runner does, so you do not repeat the setup above by hand:
- generates the plugin’s
wrangler.jsoncand.dev.varsinto a temp directory it owns, fromwrangler.example.jsonc, withmainandmigrations_dirmade absolute and the vars set (PUBLIC_ORIGIN,ENVIRONMENT=development,DEE_WAN_DEV_ORIGINS= the run’s admin Worker). Nothing is written underplugins/scheduling/. The directory goes when the run stops, and one left by a killed run (nobody listens on its owner socket any more,/tmp/dwe2e-<suffix>.sock, named after the directory) is removed when the next run starts the plugin; - applies the plugin’s migrations to a fresh local D1 in that directory, then serves it with
wrangler dev --test-scheduled; - starts the admin Worker with
PLUGIN_DEV_ORIGINSnaming the plugin; - generates a per-run
ADMIN_SECRETandTOKEN_KEY, and hands the spec the origin and the admin secret asCYPRESS_E2E_PLUGIN_ORIGINandCYPRESS_E2E_PLUGIN_ADMIN_SECRET. The spec mints its activation code with that secret, installs throughPOST /api/plugins, and opens each plugin page from the code-bearing URL ofPOST /api/plugins/:id/launch, the call the Settings and entry buttons make.
The plugin port is E2E_PLUGIN_PORT (default 43190). It is refused up front if taken, like every other
port the run serves. The runner supervises the plugin: if it dies under a live browser run, the run stops
and names it.
Failure fixtures
Section titled “Failure fixtures”How to produce each failure on purpose. The fake-core column is what your test does. The local Dee Wan column is how the same state arises against a real core, when you want to confirm it end to end.
Outcomes the protocol defines
Section titled “Outcomes the protocol defines”Every plugin meets these, whatever it does. Each is a named outcome.code with a fixed HTTP status
and retry flag.
| Outcome | Against a fake core | Against a local Dee Wan |
|---|---|---|
invalid_request |
Send an unknown body key, or omit Idempotency-Key |
Same |
plugin_unauthorized |
Use an unknown token | Disable or uninstall the installation, then send a command |
capability_refused |
Remove the capability from the installation | Narrow the grant in Settings → Plugins |
requester_unauthorized |
Mark the delegation’s person as no longer permitted | Remove the person’s role, or deactivate them, after the delegation is made |
content_not_found |
Ask for an unknown instance | Use an id from another site, or remove the model from the grant |
scheduled_target_stale |
Change the content’s current_version_id |
Edit and save the draft after pinning it |
workflow_conflict |
Bump the content’s revision |
Publish and then unpublish the item by hand after pinning a publish |
dependent_content |
Answer dependent_content by injection |
Relate another published item to it, then try to unpublish |
idempotency_key_reused |
Reuse a key with a different body | Same |
transition_unavailable |
Ask for a to the state does not offer |
Publish by hand an item whose publish is pinned. Core checks the transition before the revision |
rate_limited |
Inject 429 with a Retry-After, in seconds and as an HTTP date |
120 requests in a minute for one installation |
temporary_failure |
Inject a structured 503 response, then let the retry through | Hard to force. Core also answers it while a publish’s redirect outcome is not recorded yet; the retry with the same key answers the whole result. Trust the injected case |
Failures without an outcome code
Section titled “Failures without an outcome code”These have no outcome.code, so the protocol fixes only the rule — never treat one as success, and
never retry forever. How to record them is your plugin’s decision. The last column is what the
reference plugin does.
| Failure | Against a fake core | Against a local Dee Wan | Reference plugin |
|---|---|---|---|
| bare transient HTTP | Inject 429, 500, 502, 503 and 504 without an outcome body | Hard to force. Trust the injected cases | Retries with bounded backoff or valid bounded Retry-After |
| transport failure | Inject a network error, and a timeout | Stop the Dee Wan Worker, then let the plugin send a command | Retries as no_response, with backoff, until its caps |
| untrusted response | Inject a 200 whose outcome.code is a 409 code, a non-JSON body, and a 3xx |
Not producible. That is the point of injecting it | Refuses as untrusted_response |
| lease recovery | Leave a record running with an expired lease |
Kill the plugin mid-command and let the next tick reclaim it | Resends with the same idempotency key, within the lateness cap |
Assert the terminal code and that no new request is sent after settlement. Separately prove recovery: a lost reply may produce repeated requests with the same key and byte-identical body, but only one applied effect. Race two workers and fence a stale owner before sending. Recorded commands alone do not prove at-most-once effects.
Gaps, stated plainly
Section titled “Gaps, stated plainly”- The contract package is not published.
@dee-wan/plugin-conformanceis built and tested in the Dee Wan repository. Publishing it to npm needs authority. Until then a plugin in another repository installs a tarball made withnpm pack. - The cross-service run covers the cases in its table and nothing else. It does not drive
requester_unauthorized,capability_refused,rate_limited, token rotation,retry_exhausted, a plugin crash mid-command, or a cron trigger that fires on its own. Those are covered on each side against a double, or by hand with the local setup above. Dee Wan’s CI does not run it, and the plugin’s own CI runs only once the plugin has its own repository. - Nothing here is deployed evidence. Both services run under local
wrangler dev.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes