Skip to content

Commit f868e68

Browse files
docs(plugin-spec): Plugin Testing teaches bootStack and the in-process handle of @objectstack/verify, not the non-existent @objectstack/testing (#15952) (#22910)
Part of #15952 Clause-②: no Docs-only. This PR lands the docs half of #15952; the card stays open for the scaffold half, which is held (see below). ## What changed **`content/docs/protocol/kernel/plugin-spec.mdx`, "Plugin Testing"** is the one file in the diff (`git diff --stat origin/main...HEAD`: 1 file, +84 / -62). - The section is rewritten around what ships. The ghost callout and all seven `@objectstack/testing` / `createTestContext` / `createTestEnvironment` mentions are gone. - The page is hand-written: it is listed in `scripts/docs-audit/handwritten-docs.json`, and no generator writes it. - It now covers `bootStack`, the in-process handle (a method table), refusal assertions on `code` and status, a runtime plugin booted through `extraPlugins`, and `bootStackOnce`. - It uses only handle methods that exist in the published `@objectstack/verify@17.7.0` and on `main`. Each was read from `packages/verify/src/handle.ts` and from the 17.7.0 tarball's README and `.d.ts`. No changeset: nothing here publishes (`skip-changeset`). ## The scaffold half is held Branch `claude/issue-15952-scaffold-held`, at `812d8dbf94`, keeps the full implementation. It has no PR of its own. It contains: - the blank template's `test` script, `@objectstack/verify` + `vitest` devDependencies, `vitest.config.ts` and the one starter test `note.test.ts`; - `create-objectstack`'s template exclude; - the Scaffold E2E `test` step; - the `create-objectstack` minor changeset. On that head a freshly scaffolded app runs `pnpm test` green. The half is held, on the seat's order on the card, for two measured reasons: 1. **#22912: `npm install` of a vitest-4 project crashes on npm 10 (upstream).** Node 22's bundled npm 10.9.x fails with `TypeError: Cannot read properties of null (reading 'edgesOut')`. The one-line repro is a manifest holding only `"vitest": "^4.0.0"`. Shipping the starter's vitest devDependency would break `npm install` for the blank starter on Node 22. The half resumes when #22912's one-line manifest installs on npm 10. 2. **Open question 1 on #15952: two required repo gates count the template payload test as `create-objectstack`'s own suite.** The gates are `check-registry-log-declared` and `check:test-source-alias`. Both walk every `*.test.ts` under a package without the tsconfig `exclude` payload boundary that `check-undeclared-dep-imports` already reads, and both exited 1 at `812d8dbf94`. That question is deferred with the held half. ## Verification (head `dfead30310`) - **Acceptance grep:** `git grep -n "objectstack/testing" -- content/ skills/ packages/` finds 0 hits (exit 1). The three-symbol expression `objectstack/testing\|createTestContext\|createTestEnvironment` also finds 0 hits (exit 1). Before, at `9fdb12752c`, there were 7 hits, all in this page. - **Gates:** `dispatch-gates --commands` was derived with no paths at `dfead30310`: 40 families. All 40 were run, plus `check-registry-log-declared` and `check:test-source-alias`, the two that were red with the payload test, and every one exited 0. `--ran` reconciles 40 run, 0 NOT-MEASURED, 0 UNRUN. - **Anchors:** `check:doc-anchors` resolves every internal `#fragment` link, including the section's new `#runtime-contract` link. ## Acceptance notes - The published skills still teach a hand-assembled `LiteKernel` test (`skills/objectstack-platform/references/operations.md`, and `references/plugin-development.md` under "Testing Plugins"). None mentions `@objectstack/testing`, so the acceptance grep is clean. Pointing them at `bootStack` is a governed (Tier H) `skills/**` edit. --- _Generated by [Claude Code](https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1bd612b commit f868e68

1 file changed

Lines changed: 84 additions & 62 deletions

File tree

‎content/docs/protocol/kernel/plugin-spec.mdx‎

Lines changed: 84 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -784,81 +784,103 @@ os package install ./dist/objectstack.json
784784

785785
## Plugin Testing
786786

787-
<Callout type="warn">
788-
The `@objectstack/testing` package and its `createTestContext()` /
789-
`createTestEnvironment()` helpers shown below are **proposed** — there is no
790-
such published package today. The snippets illustrate the intended test
791-
ergonomics for the plugin spec, not a shipping API.
792-
</Callout>
787+
Test a plugin the way it runs: boot a real stack that loads it, drive the
788+
engine, and assert on what the engine did. `@objectstack/verify` ships the
789+
harness for this, and unlike the manifest snippets above, the code in this
790+
section is the shipped API.
791+
792+
```bash
793+
pnpm add -D @objectstack/verify vitest
794+
```
793795

794-
### Unit Tests
796+
`bootStack(config, options?)` boots a configuration in-process: the real kernel
797+
with ObjectQL, the bound hook chain, validation, the security middleware,
798+
sharing and the REST dispatcher, on an in-memory database, with no server
799+
process and no port. The stack it returns carries an **in-process handle**.
800+
Each handle method passes the call to the door the kernel wired at boot (the
801+
engine's own write, dry-run and read calls, or the runtime's `/automation` and
802+
`/actions` routes driven without HTTP) and returns what that door returned. A
803+
test never builds its own `ctx`, never orders hooks by hand and never copies a
804+
permission check, so it keeps telling the truth when the engine changes.
805+
806+
### Booting the stack
795807

796808
```typescript
797-
// tests/triggers/account_validation.test.ts
798-
import { createTestContext } from '@objectstack/testing';
799-
import accountValidation from '../../src/triggers/account_validation';
800-
801-
describe('Account Validation Trigger', () => {
802-
it('should reject negative revenue', async () => {
803-
const context = createTestContext();
804-
const record = { annual_revenue: -1000 };
805-
809+
// test/crm.test.ts
810+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
811+
import { bootStack, type VerifyStack } from '@objectstack/verify';
812+
import config from '../objectstack.config';
813+
814+
describe('CRM plugin', () => {
815+
let stack: VerifyStack;
816+
let admin: string;
817+
let rep: string;
818+
819+
beforeAll(async () => {
820+
stack = await bootStack(config);
821+
admin = await stack.signIn(); // the dev admin the stack seeds
822+
rep = await stack.signUp('rep@example.com'); // a fresh member: the default profile only
823+
});
824+
825+
afterAll(async () => {
826+
await stack.stop();
827+
});
828+
829+
it('refuses negative revenue', async () => {
830+
// One real write: before* hooks, validation, the driver, after* hooks,
831+
// and the permission check the caller is subject to.
806832
await expect(
807-
accountValidation.execute({ record, context })
808-
).rejects.toThrow('Annual revenue cannot be negative');
833+
stack.hooks.run('crm_account', 'insert', { name: 'Acme Corp', annual_revenue: -1000 }, { as: admin }),
834+
).rejects.toMatchObject({ code: 'VALIDATION_FAILED' });
809835
});
810-
811-
it('should generate account number', async () => {
812-
const context = createTestContext();
813-
const record = { name: 'Acme Corp' };
814-
815-
const result = await accountValidation.execute({ record, context });
816-
817-
expect(result.account_number).toMatch(/^ACC-\d{6}$/);
836+
837+
it('numbers a new account', async () => {
838+
const account = await stack.hooks.run('crm_account', 'insert', { name: 'Acme Corp' }, { as: admin });
839+
expect(account.account_number).toMatch(/^ACC-\d{6}$/);
840+
});
841+
842+
it('keeps accounts away from a member without a grant', async () => {
843+
await expect(stack.rows('crm_account', {}, { as: rep }))
844+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 });
818845
});
819846
});
820847
```
821848

822-
### Integration Tests
849+
Assert on a refusal's `code` (and its status), never on its message alone.
850+
A refusal from `hooks.run`, `validate` or `rows` is the engine's own error,
851+
rethrown unchanged; one from `flows.*` or `actions.run` is the route's error
852+
envelope (`code`, `status`, `details`), which `isVerifyRefusal(e)` recognises.
823853

824-
```typescript
825-
// tests/integration/crm_workflow.test.ts
826-
import { createTestEnvironment } from '@objectstack/testing';
854+
### What the handle drives
827855

828-
describe('CRM Workflow', () => {
829-
let env;
830-
831-
beforeAll(async () => {
832-
env = await createTestEnvironment({
833-
plugins: ['@mycompany/crm'],
834-
});
835-
});
836-
837-
it('should create account with contacts', async () => {
838-
// Create account
839-
const account = await env.objectQL.create('account', {
840-
name: 'Test Corp',
841-
industry: 'technology',
842-
});
843-
844-
// Create contact
845-
const contact = await env.objectQL.create('contact', {
846-
first_name: 'John',
847-
last_name: 'Doe',
848-
account: account.id,
849-
});
850-
851-
// Verify relationship
852-
const accountWithContacts = await env.objectQL.findById('account', account.id, {
853-
include: ['contacts'],
854-
});
855-
856-
expect(accountWithContacts.contacts).toHaveLength(1);
857-
expect(accountWithContacts.contacts[0].id).toBe(contact.id);
858-
});
859-
});
856+
| Method | Door it drives |
857+
|:---|:---|
858+
| `hooks.run(object, operation, input, { as })` | One `'insert'`, `'update'` or `'delete'` through the engine, hooks, validation and permission checks included. `update` and `delete` address the row by `input.id`. |
859+
| `validate(object, record, { as, mode? })` | The engine's validation pass without writing; resolves `{ valid, results }`. Hooks do not run. |
860+
| `flows.run(name, params, { as })` / `flows.resume(run, input, { as })` | A flow through the runtime's automation route. Boot with `{ automation: true }` so the automation service is there to drive. |
861+
| `actions.run(object, action, { as, recordId?, params? })` | A declared action through its route, param contract and permission gate included. |
862+
| `seed(object, rows)` | Fixture rows written as the system, the way a stack's `data` is seeded: hooks and validation run, record-change flows do not. |
863+
| `rows(object, where?, { as? })` | A read through the engine, as the system by default or as `as` under that caller's grants and row-level security. |
864+
| `metadata` · `tenancy()` · `contextFor(token)` | The booted registry, the tenancy posture, and the execution context the platform resolves for a caller. |
865+
866+
`as` is always a bearer token from `signIn()` or `signUp()` on the same stack.
867+
868+
### A runtime plugin
869+
870+
A plugin class (see [Runtime Contract](#runtime-contract)) boots with the stack
871+
through `extraPlugins`, so its `init` and `start` run against the real kernel,
872+
and the services it registers resolve from `stack.kernel`:
873+
874+
```typescript
875+
const stack = await bootStack(config, { extraPlugins: [new CRMPlugin()] });
876+
const crm = stack.kernel.getService<CRMService>('crm');
860877
```
861878

879+
A pure helper with no engine behind it needs no harness: test it with plain
880+
vitest. Many test files can share one boot through `bootStackOnce(config)`,
881+
under vitest's `isolate: false`. The `@objectstack/verify` README documents
882+
every method and boot option.
883+
862884
## Plugin Development Workflow
863885

864886
### 1. Scaffold Plugin

0 commit comments

Comments
 (0)