Skip to content
Dee Wan 1.0 is not yet released (pre-MVP). See the 1.0 product boundary.

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.

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.

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:

Terminal window
cd packages/plugin-conformance # in a Dee Wan checkout
npm install
npm pack # writes dee-wan-plugin-conformance-0.1.0.tgz
cd /path/to/your-plugin
npm install --save-dev /path/to/dee-wan-plugin-conformance-0.1.0.tgz

It 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_unauthorized otherwise
  • answers /context, /content/:id and /launch/exchange with protocol-shaped bodies, including the workflow block 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_command with invalid_request
  • checks the pin and the workflow revision on a command, and answers scheduled_target_stale or workflow_conflict when they no longer hold, and dependent_content for an item marked with dependents
  • keeps an idempotency record per key, so an exact replay answers idempotent_replay with the whole first result and a reused key with different input answers idempotency_key_reused
  • reports redirect on every publish result, from the item’s redirect field, and none on unpublish
  • moves one language when a command names locale and the installation holds workflow:locale, and lists each language under workflow.locales for 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, POST and GET /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, a Retry-After header, 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:

Terminal window
cd plugins/scheduling
npm test # vitest
npm run typecheck

Inside 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.

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-readable outcomes table (HTTP status and retry flag per code), capabilities, installation_states and limits.

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.

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.

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:8788

That 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:

Terminal window
node scripts/install-dependencies.mjs
node scripts/install-dependencies.mjs frontend
npm run local:migrate
npm run local:api # admin Worker on http://localhost:8787 (leave running)
npm run local:seed # needs local:api running; prints the sign-in it creates
npm run local:admin # admin UI on http://localhost:5173 (leave running)

For the reference plugin, from the repository root:

Terminal window
cd plugins/scheduling
cp wrangler.example.jsonc wrangler.jsonc

The 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=development
PUBLIC_ORIGIN=http://localhost:8788
DEE_WAN_DEV_ORIGINS=http://localhost:8787
ADMIN_SECRET=local-admin-secret-0123456789
TOKEN_KEY=<the output of: openssl rand -base64 32>
  • .dev.vars overrides the production values in wrangler.jsonc for wrangler dev.
  • DEE_WAN_DEV_ORIGINS is the plugin’s mirror image of PLUGIN_DEV_ORIGINS: the one non-HTTPS Dee Wan base URL it accepts, and only when ENVIRONMENT=development.
  • ADMIN_SECRET is the operator bearer for the plugin’s /admin/* routes, and must be at least 16 characters.
  • TOKEN_KEY is the base64 of 32 random bytes and encrypts stored installation tokens at rest. Lose it and every installation has to be rotated.
Terminal window
npx wrangler d1 migrations apply dee-wan-scheduling --local
npx wrangler dev --port 8788 --test-scheduled # leave running
curl http://localhost:8788/dee-wan/manifest.json # the manifest names http://localhost:8788

Mint a one-time activation code:

Terminal window
curl -X POST -H "Authorization: Bearer local-admin-secret-0123456789" \
http://localhost:8788/admin/activation-codes

It 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.

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-/Secure session cookie needs HTTPS or localhost exactly — not 127.0.0.1.
  • A cron trigger does not fire in wrangler dev unless you ask it to. With --test-scheduled, curl "http://localhost:8788/__scheduled?cron=*+*+*+*+*" runs one tick.

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:

Terminal window
node scripts/install-dependencies.mjs
npm run build:packages
npm --workspace backend run prisma:generate
cd plugins/scheduling
npm run test:cross-service

The 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, with PLUGIN_DEV_ORIGINS naming the plugin.
  • the Scheduling plugin under wrangler dev --test-scheduled, from a generated copy of wrangler.example.jsonc, on a fresh migrated local D1.
  • an HTTP proxy in the test process. Core’s BACKEND_URL names it, so every plugin call to core passes through it. It forwards requests and replies unchanged, apart from the Host header. 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.

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.

Terminal window
E2E_REAL_BACKEND=1 npm --prefix frontend run test:e2e -- --spec cypress/e2e/real-scheduling-releases.cy.ts

What the runner does, so you do not repeat the setup above by hand:

  • generates the plugin’s wrangler.jsonc and .dev.vars into a temp directory it owns, from wrangler.example.jsonc, with main and migrations_dir made absolute and the vars set (PUBLIC_ORIGIN, ENVIRONMENT=development, DEE_WAN_DEV_ORIGINS = the run’s admin Worker). Nothing is written under plugins/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_ORIGINS naming the plugin;
  • generates a per-run ADMIN_SECRET and TOKEN_KEY, and hands the spec the origin and the admin secret as CYPRESS_E2E_PLUGIN_ORIGIN and CYPRESS_E2E_PLUGIN_ADMIN_SECRET. The spec mints its activation code with that secret, installs through POST /api/plugins, and opens each plugin page from the code-bearing URL of POST /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.

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.

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

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.

  • The contract package is not published. @dee-wan/plugin-conformance is 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 with npm 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