Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
34dce6f
feat: createAppHarness and a stub admin built only on core's contract…
zumbrunn Nov 5, 2026
807765a
feat: admin conformance suite (runAdminConformance) with the stub adm…
zumbrunn Nov 5, 2026
19cb005
fix(conformance): tolerate an onPageUpdate fired after the response
zumbrunn Nov 5, 2026
fd7ba6c
feat(conformance): check that the writer fires onPageCreate and onPag…
zumbrunn Nov 5, 2026
8f7f026
feat(stub-admin): mount() reads MountApi.runtime (#97)
zumbrunn Nov 5, 2026
3a06838
feat(conformance)!: read the admin's prefix from the admin.prefix slot
zumbrunn Nov 6, 2026
3c3c5db
chore: version 2.0.0 (the conformance driver lost adminPrefix)
zumbrunn Nov 6, 2026
7000678
feat(harness)!: createTestHarness has no disableAdmin option
zumbrunn Nov 7, 2026
72ce003
fix: the stub admin grants on app:site
zumbrunn Nov 7, 2026
695b1d8
chore: use the editor.session and editor.inline slot names (duneorg/d…
zumbrunn Nov 7, 2026
14a0a78
feat!: admin.pages / admin.routes are the admin plugin's extension po…
zumbrunn Nov 7, 2026
4527f8b
feat!: export extension points as typed capability keys, not module a…
zumbrunn Nov 8, 2026
42ddd7c
style: deno fmt (CHANGELOG, README, conformance)
zumbrunn Nov 10, 2026
da1f68e
Merge branch 'feat/conformance-admin-prefix' into feat/own-contributions
zumbrunn Nov 10, 2026
d343527
style: deno fmt the changelog
zumbrunn Nov 10, 2026
ed7d572
chore: require @dune/core ^0.36
zumbrunn Nov 10, 2026
9286c9a
Merge commit 'ed7d5721ce29f73ee4b97e920c0878f0a08c283f' into HEAD
zumbrunn Nov 10, 2026
1217d78
docs: date the 2.0.0 changelog entry as the release
zumbrunn Nov 10, 2026
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
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
# Changelog

## [Unreleased]

## [2.0.0] — 2026-11-10

The conformance driver changed incompatibly.

### Changed — BREAKING

- **`admin.pages` / `admin.routes` are no longer core's keys (#127).** They are
the extension points `@dune/plugin-admin` declares, so `hostsContributions`
now means "this admin hosts the admin plugin's extension points" and the suite
reaches them through an untyped view of the registry instead of depending on
one admin's types.
- **The conformance driver has no `adminPrefix`.** The admin's URL prefix comes
from the `admin.prefix` capability slot, which an admin must now provide (core
no longer assumes `/admin`). `adminPrefix?: string` is replaced by
`hostsContributions?: boolean`, which only enables the `admin.pages` /
`admin.routes` checks. New checks: the admin provides `admin.prefix` (one
provider, a usable prefix), core reserves that prefix against plugin routes,
and with no admin `/admin` is an ordinary plugin path. The stub admin provides
`admin.prefix` (`/stub-admin` by default). Needs `@dune/core` 0.36 (dune#123).
- **`createTestHarness({ disableAdmin })` is gone.** Core no longer loads an
admin by itself, so there is nothing to disable; pass an admin in `plugins` to
test with one.

### Added

- **`runAdminConformance()`** (`@dune/testing/conformance`): the admin
conformance suite. A driver says how to build an app with an admin installed
and how to sign an editor in; the suite checks the contract with core (the
`editor.session` capability, session recognition and marker scrubbing, the
declared session cookie, mount-order independence, `onPageUpdate` /
`onPageCreate` / `onPageDelete` from the writer, `admin.pages` /
`admin.routes` mounting, and the site serving with no admin). Also
`adminConformanceChecks()` to run the checks yourself. Its own tests include
deliberately broken admins that it must reject. The stub admin is the
reference run.
- **`createAppHarness()`** (`@dune/testing/app-harness`): the in-memory harness
plus the full `createDuneApp()` app, with `request(path)` going through plugin
routes, the response-transform pipeline and the content catch-all.
- **`createStubAdmin()`** (`@dune/testing/stub-admin`): a minimal replacement
admin built only on core's injected services, capabilities and mount API. It
proves Dune does not depend on `@dune/plugin-admin`: its tests cover login and
sessions, `editor.session` recognised by core's transform pipeline (and only
its own cookie), marker scrubbing, a page edit that fires `onPageUpdate`, a
role without edit rights, and a site with no admin at all. Needs `@dune/core`
0.36 (duneorg/dune#97); version bump and the `^0.36` pin are release work.

## [1.0.3] — 2026-10-28

### Fixed
Expand Down
99 changes: 92 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,100 @@ Returns a `TestHarness` with:
| `rebuild()` | Trigger a full content rebuild and search index update |
| `dispose()` | Tear down the harness — safe to call multiple times |

### `createAppHarness(options?)`

`createTestHarness()` reaches only the read-only content API.
`createAppHarness()` also builds the app `dune serve` builds, so a plugin's
`mount()` routes, the response-transform pipeline and the inline-edit WebSocket
route are all reachable in process. Same options as `createTestHarness()`; the
result adds `request(path, init?)`, which goes through the whole app.

```ts
const h = await createAppHarness({ content, plugins: [myPlugin()] });
const res = await h.request("/about/");
```

Folder pages are served at their canonical path with a trailing slash
(`/about/`); `/about` answers 301.

### `createStubAdmin(options?)`

A minimal replacement admin, built only on the services, capabilities and mount
API core hands every plugin (it imports nothing from `@dune/plugin-admin`). It
keeps users and sessions through `services.users` and `services.sessions`,
provides the `editor.session` and `scheduler.executor` capabilities, seeds authz
tuples through `services.authz`, and offers its own `POST /stub-admin/login`,
`GET /stub-admin/pages` and `POST /stub-admin/pages/edit` (which, as the writer,
fires `onPageUpdate`). Use it to check that your plugin works with an admin that
is not the built-in one, or to build a conformance test for an admin of your
own. `declareCookie: false` makes it a provider that does not say which cookie
carries its session.

```ts
const admin = createStubAdmin({ users: [{ username: "ed", password: "pw" }] });
const h = await createAppHarness({
content,
plugins: [admin.plugin, myPlugin()],
});
```

### `runAdminConformance(driver)`

What "meets the contract" means for an admin. Dune does not depend on
`@dune/plugin-admin`: any plugin that provides the `editor.session` capability
is the admin as far as core is concerned. Give the suite a driver (how to build
an app with your admin, how to sign an editor in) and it registers these tests:

- the admin provides `editor.session` (exactly one provider, active);
- the public site serves, and anonymous visitors are not admins;
- core's response pipeline recognises an editor session and **only** a real one:
no cookie, an unrelated cookie and a forged session cookie get no auth and
have editing markers scrubbed; the editor gets `pages.update` and keeps the
markers;
- the cookie the admin declares in `sessionCookies` is the cookie its sign-in
sets (otherwise core would never consult it, and its pages could enter the
shared cache);
- an account without editing rights gets no editing chrome (`signInViewer`);
- a route in a plugin mounted before the admin can still tell who the admin is
through `editor.session`;
- editing a page through the admin writes it and fires `onPageUpdate` exactly
once (`editPage`);
- creating a page fires `onPageCreate` exactly once and deleting one fires
`onPageDelete` exactly once, each with the path relative to the content
directory (`createPage`, `deletePage`);
- the admin provides `admin.prefix` (where it is mounted: one provider, a path
starting with `/` and with no trailing slash), and core reserves that prefix
against plugin routes;
- `admin.pages` and `admin.routes` contributed by another plugin are mounted
behind the login, the CSRF check and the permission (`hostsContributions`);
- with no admin at all the site serves, nothing admin-related exists, and
`/admin` is an ordinary path for plugins.

```ts
runAdminConformance({
name: "my-admin",
async createApp({ content, extraPlugins, withAdmin }) {
/* build the app; register extraPlugins BEFORE your admin */
},
signInEditor: async (app) => "my_session=abc123", // the Cookie header value of an editor
});
```

The optional hooks (`signInViewer`, `editPage`, `createPage`, `deletePage`,
`hostsContributions`) enable the checks that need them. The admin's prefix is
not a driver option: the suite reads it from the `admin.prefix` slot.
`adminConformanceChecks(driver)` returns the checks without registering them,
which is how the package tests that the suite rejects a broken admin. The stub
admin (`createStubAdmin()`) is the reference run.

### Options

| Option | Default | Description |
| -------------- | ------------------ | -------------------------------------------------------------------- |
| `content` | `{}` | Fixture content keyed by path relative to `content/` |
| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) |
| `plugins` | `[]` | Plugin instances to register before bootstrap |
| `siteTitle` | `"Dune Test Site"` | Site title override |
| `disableAdmin` | `true` | Disable the admin plugin (heavy; skip unless testing admin behavior) |
| Option | Default | Description |
| ----------- | ------------------ | ------------------------------------------------------------------ |
| `content` | `{}` | Fixture content keyed by path relative to `content/` |
| `files` | `{}` | Extra files at arbitrary paths (config overrides, flex data, etc.) |
| `plugins` | `[]` | Plugin instances to register before bootstrap |
| `siteTitle` | `"Dune Test Site"` | Site title override |

## License

Expand Down
26 changes: 16 additions & 10 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
{
"name": "@dune/testing",
"version": "1.0.3",
"version": "2.0.0",
"license": "MIT",
"exports": {
".": "./src/mod.ts",
"./harness": "./src/harness.ts"
"./harness": "./src/harness.ts",
"./app-harness": "./src/app-harness.ts",
"./stub-admin": "./src/stub-admin.ts",
"./conformance": "./src/conformance.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@^1",
"@std/yaml": "jsr:@std/yaml@^1",
"@dune/core": "jsr:@dune/core@^0.35",
"@dune/core/storage": "jsr:@dune/core@^0.35/storage",
"@dune/core/api": "jsr:@dune/core@^0.35/api",
"@dune/core/bootstrap": "jsr:@dune/core@^0.35/bootstrap",
"@dune/core/engine": "jsr:@dune/core@^0.35/engine",
"@dune/core/hooks": "jsr:@dune/core@^0.35/hooks",
"@dune/core/search": "jsr:@dune/core@^0.35/search",
"@dune/core/config": "jsr:@dune/core@^0.35/config"
"@dune/core": "jsr:@dune/core@^0.36",
"@dune/core/storage": "jsr:@dune/core@^0.36/storage",
"@dune/core/api": "jsr:@dune/core@^0.36/api",
"@dune/core/bootstrap": "jsr:@dune/core@^0.36/bootstrap",
"@dune/core/engine": "jsr:@dune/core@^0.36/engine",
"@dune/core/hooks": "jsr:@dune/core@^0.36/hooks",
"@dune/core/search": "jsr:@dune/core@^0.36/search",
"@dune/core/config": "jsr:@dune/core@^0.36/config",
"@dune/core/fresh-app": "jsr:@dune/core@^0.36/fresh-app",
"@dune/core/workflow": "jsr:@dune/core@^0.36/workflow",
"@dune/core/plugin-api": "jsr:@dune/core@^0.36/plugin-api"
},
"minimumDependencyAge": {
"age": "P1D",
Expand Down
64 changes: 64 additions & 0 deletions src/app-harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* createAppHarness — the in-memory test harness plus the full Fresh app.
*
* `createTestHarness()` reaches only the read-only content API. This one also
* builds the app `dune serve` builds (`createDuneApp()`): content pages, plugin
* routes, the response-transform pipeline, the inline-edit WebSocket route,
* everything a plugin's `mount()` registers. Use it to test a plugin or an
* admin end to end, in process, without a server.
*
* ```ts
* import { createAppHarness } from "@dune/testing";
*
* const h = await createAppHarness({
* content: { "01.home/default.md": "---\ntitle: Home\n---\nHello" },
* plugins: [myPlugin()],
* });
* try {
* const res = await h.request("/");
* assertEquals(res.status, 200);
* } finally {
* await h.dispose();
* }
* ```
*
* @module
*/

import { createDuneApp } from "@dune/core/fresh-app";
import { createTestHarness } from "./harness.ts";
import type { TestHarness, TestHarnessOptions } from "./harness.ts";

/** A {@link TestHarness} that also serves the full Dune app. */
export interface AppHarness extends TestHarness {
/**
* Send an in-process HTTP request through the whole app (page cache,
* plugin routes, response transforms, content catch-all).
*/
request(path: string, init?: RequestInit): Promise<Response>;
}

/**
* Options for {@link createAppHarness}: those of {@link createTestHarness}, with
* no admin unless the test passes one in `plugins`.
*/
export type AppHarnessOptions = TestHarnessOptions;

/** Create an in-process Dune app over in-memory storage. */
export async function createAppHarness(
options: AppHarnessOptions = {},
): Promise<AppHarness> {
const h = await createTestHarness(options);
const { app } = await createDuneApp(h.bootstrap, {
root: "/dune-test-harness",
port: 0,
dev: false,
});
const handler = app.handler();
return {
...h,
request(path, init) {
return handler(new Request(new URL(path, "http://localhost:8000"), init));
},
};
}
Loading
Loading