Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions .changeset/15952-blank-template-test-story.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'create-objectstack': minor
---

The blank starter ships a test story: `pnpm test` boots the scaffolded app's own stack with `bootStack` from `@objectstack/verify` and asserts one business fact through its in-process handle

Clause-②: no

A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries:

- **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes.
- **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log.
- **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not.
- **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config.
- **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`.

The project's CI workflow is unchanged: its steps are the script set every scaffolder's project must declare, and `objectstack init -t app` declares no `test` script.
18 changes: 16 additions & 2 deletions .github/workflows/scaffold-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#
# Job 1 (PRs touching the scaffolder / docker example) scaffolds with the
# repo-built scaffolder and runs the generated project against the registry:
# install → validate → build → boot → health probes → docker build/run. That
# install → validate → build → test → boot → health probes → docker build/run. That
# run scaffolds with `--skip-install` and installs the project itself, so it
# exercises the scaffolder's `--skip-install` shape; a second, throwaway
# project is scaffolded WITHOUT it purely to assert the shape a real user
Expand Down Expand Up @@ -148,6 +148,16 @@ jobs:
npm run validate
npm run build

# The starter's own test (`note.test.ts`): it boots the generated
# project's stack in-process through @objectstack/verify, as installed
# from the registry, and asserts one business fact on the real engine.
# A scaffold whose `test` script is red out of the box teaches its user
# to ignore tests, so the starter ships it green or not at all.
- name: Test the generated project
run: |
cd "$RUNNER_TEMP/e2e-app"
npm test

# Two questions this step must answer about its own server, and used to
# answer neither (#9779). Both belong here rather than in the CLI: `os
# start` is already correct — the shell around it was not.
Expand Down Expand Up @@ -486,14 +496,18 @@ jobs:

# `build` is the gate every template must pass; `validate` is run only
# when the scaffolded project defines it (the now-retired remote
# templates did not always ship it).
# templates did not always ship it), and `test` likewise: a published
# scaffolder older than the starter's `note.test.ts` declares none.
- name: Gate the generated project
run: |
cd "$RUNNER_TEMP/canary-app"
if node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.validate ? 0 : 1)"; then
npm run validate
fi
npm run build
if node -e "process.exit(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts?.test ? 0 : 1)"; then
npm test
fi

# Same two guards, same reasoning, as `Boot from the artifact and probe
# health` in scaffold-local — see the measurement written out there. Kept
Expand Down
3 changes: 3 additions & 0 deletions content/docs/getting-started/your-first-project.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ my-app/
├── objectstack.manifest.json # name, namespace, specVersion
├── package.json
├── tsconfig.json
├── note.test.ts # the starter test `npm test` runs
├── vitest.config.ts # its test-runner config
├── AGENTS.md # conventions for coding agents
└── src/
├── objects/
Expand Down Expand Up @@ -232,6 +234,7 @@ framework surface you install:
| `@objectstack/plugin-hono-server` | The HTTP server that mounts the generated REST API. |
| `@objectstack/connector-rest` · `-openapi` · `-mcp` | The three generic connector executors — register the `rest` / `openapi` / `mcp` provider factories so declarative `connectors:` entries materialize ([ADR-0097](/docs/automation/flows)). |
| `@objectstack/cli` *(dev)* | The `os` / `objectstack` CLI: `dev`, `validate`, `build`, `start`. |
| `@objectstack/verify` *(dev)* | The test harness: `bootStack` boots this app's own stack in-process, on an in-memory database and with no server port, and returns the handle a test asserts through. `npm test` runs the starter test `note.test.ts` with it. |

No driver package is listed because none is installed directly: `@objectstack/runtime`
depends on `driver-sql`, `driver-sqlite-wasm` and `driver-memory`, and the CLI carries
Expand Down
10 changes: 10 additions & 0 deletions packages/create-objectstack/src/templates/blank/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ for OAuth, API keys, and which objects/actions become tools.
stack, so `objectstack generate flow NAME` (or `view`, `action`, …) adds a
file and one export line, and the item is loaded, and counted by
`pnpm validate`, with no edit to the config
- `note.test.ts` — the starter test `pnpm test` runs, configured by
`vitest.config.ts`

## Connectors (default providers)

Expand Down Expand Up @@ -112,13 +114,21 @@ pnpm validate # schema + CEL predicates + widget bindings (no artifact)
pnpm lint # authoring rules validate does not run — e.g. a handler that
# stopped lowering to a metadata-only body
pnpm typecheck # TypeScript types against @objectstack/spec
pnpm test # your app's own tests, against the real engine
```

`pnpm validate` runs the same gates as `pnpm build` and catches mistakes that
otherwise fail *silently at runtime* — e.g. a bare `done` (instead of
`record.done`) in an action predicate that would hide the action on every
record. See `AGENTS.md` for the full convention.

`pnpm test` checks what the app *does*. `note.test.ts` boots this app's stack
in-process with `bootStack` from `@objectstack/verify` (in-memory database, no
server) and asserts one business fact through its handle: a note without a
title is refused. Write your own the same way: `stack.hooks.run` makes a real
write through validation, permissions and hooks, and the same handle runs flows
and actions, seeds fixtures and reads rows as a given user.

## Deploy

The project ships container-ready — the `Dockerfile` builds your metadata into
Expand Down
54 changes: 54 additions & 0 deletions packages/create-objectstack/src/templates/blank/note.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import config from './objectstack.config';
import { Note } from './src/objects';

// Boots THIS app (its objects, its plugins, the services it requires) on the
// real ObjectStack engine, in this process, with an in-memory database and no
// server port, then asserts on what the engine does. Nothing is mocked: a
// write through `stack.hooks.run` takes the same path as a write over the REST
// API, through validation, the permission checks and every hook the app binds.
//
// The same handle runs a flow (`stack.flows.run`), an action
// (`stack.actions.run`), a validation pass without writing (`stack.validate`),
// seeds fixtures (`stack.seed`) and reads rows as a given user (`stack.rows`).
// The README of `@objectstack/verify` (in node_modules) documents each one.
//
// The object is addressed as `Note.name`, never as a typed-out string:
// scaffolding renamed it to carry this project's namespace, and adding your
// own objects works the same way.
describe('notes', () => {
let stack: VerifyStack;
let author: string;

beforeAll(async () => {
stack = await bootStack(config);
// The development admin the stack seeds: a bearer token to act as.
author = await stack.signIn();
});

afterAll(async () => {
await stack?.stop();
});

it('cannot be saved without a title', async () => {
// `title` is declared `required` in src/objects/note.object.ts, and the
// engine refuses the write with its own error, naming the field.
await expect(
stack.hooks.run(Note.name, 'insert', { body: 'Agenda for Monday' }, { as: author }),
).rejects.toMatchObject({
code: 'VALIDATION_FAILED',
fields: [{ field: 'title', code: 'required' }],
});

// The same note with a title is saved, so the refusal above was the
// missing title and nothing else.
const note = await stack.hooks.run(
Note.name,
'insert',
{ title: 'Weekly sync', body: 'Agenda for Monday' },
{ as: author },
);
expect(note.title).toBe('Weekly sync');
});
});
7 changes: 5 additions & 2 deletions packages/create-objectstack/src/templates/blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"build": "objectstack build",
"validate": "objectstack validate",
"lint": "objectstack lint",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@objectstack/spec": "^17.0.0",
Expand All @@ -24,6 +25,8 @@
},
"devDependencies": {
"@objectstack/cli": "^17.0.0",
"typescript": "^5.3.0"
"@objectstack/verify": "^17.0.0",
"typescript": "^5.3.0",
"vitest": "~4.0.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"noEmit": true,
"isolatedModules": true
},
"include": ["objectstack.config.ts", "src/**/*"]
"include": ["*.ts", "src/**/*"]
}
21 changes: 21 additions & 0 deletions packages/create-objectstack/src/templates/blank/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config';

// `pnpm test` runs every `*.test.ts` in this project. A test that boots the
// app (see note.test.ts) runs the real engine in this process, so two settings
// differ from vitest's defaults.
export default defineConfig({
test: {
// Booting the stack takes a few seconds, longer on a busy CI runner, and
// the boot runs in a `beforeAll` hook: allow it more than the default 10s.
hookTimeout: 60_000,
// The stack logs while it boots and while it shuts down. A log line that
// arrives while vitest is closing a test worker can fail a run in which
// every test passed. Printing logs straight to the terminal, instead of
// through vitest's console capture, removes that failure.
disableConsoleIntercept: true,
// At its default level the engine logs one `[Registry]` line for every
// item it registers while booting, over a hundred for this starter alone.
// `warn` keeps the engine's warnings and errors and drops that chatter.
env: { OS_REGISTRY_LOG: 'warn' },
},
});
10 changes: 9 additions & 1 deletion packages/create-objectstack/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
import { configDefaults, defineConfig } from 'vitest/config';

const HERE = path.dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -29,6 +29,14 @@ export default defineConfig({
// Enforced repo-wide by scripts/check-console-intercept-disarm.mjs.
disableConsoleIntercept: true,
include: ['test/**/*.test.ts', 'src/**/*.test.ts'],
// `src/templates/` is PAYLOAD: copied byte-for-byte into a scaffolded
// project and never compiled here (tsconfig.json excludes it too). The
// blank template ships its own `note.test.ts`, which boots THAT project's
// stack through `@objectstack/verify` — a dependency of the scaffolded
// project, not of this package. It runs where it lands: the Scaffold E2E
// workflow scaffolds a project, installs it from the registry and runs
// its `test` script.
exclude: [...configDefaults.exclude, 'src/templates/**'],
environment: 'node',
},
});
Loading
Loading