Testing
Testing
JSails ships a server-only test subpath, jsails/testing, that assembles a
real Application in-process and routes requests through the same Hono
pipeline serve uses. No HTTP server listens, no broadcast transport is
attached, and no Valkey connection is opened automatically. Pages render through
the built-in Preact renderer (or the configured renderer) and API routes go
through the normal default-deny / session / CSRF pipeline, so server HTML and
API behavior are the real output.
Importing jsails/testing pulls in the application runtime (Hono, page
rendering, the config loader), so it is never part of the browser-safe root
entry.
The createTestApp surface
createTestApp(options?) resolves config like the runtime. Pass a raw config
object (validated by validateAppConfig) or a configPath to a compiled
config module (loaded by loadAppConfig, default jsails.app.js) resolved
against cwd. The two are mutually exclusive.
The request origin is the explicit origin (a validated http(s) origin — no
credentials, path, query, or fragment) when given, else the configured
publicOrigin, else the TEST_ORIGIN environment variable, else
http://localhost (DEFAULT_TEST_ORIGIN).
fetch(request)— route a nativeRequestdirectly; the escape hatch for deliberately exercising a non-test origin in-process.request(path, init?)— build aRequestpinned to the resolved origin and route it; rejects a path that resolves off-origin, carries credentials, or uses a non-http(s)scheme before any handler runs. Returns theResponsefor any status, so negative HTTP assertions use it.json<T>(path, init?)—requestplus a 2xx check and JSON parse; rejects withTestRequestErroron a non-2xx status or invalid JSON. Its message never embeds the response body, the underlying cause, or the request URL (which may carry secrets).close()— idempotent teardown. Passlifecycle: t(anode:testTestContext) to registert.after(...), so the app closes automatically when each test finishes.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createTestApp } from 'jsails/testing';
test('home page is served as HTML', { timeout: 30_000 }, async (t) => {
const app = await createTestApp({ lifecycle: t });
const response = await app.request('/');
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') ?? '', /text\/html/);
// The response body is caller-owned: consume it explicitly.
const html = await response.text();
assert.match(html, /<title>JSails Starter<\/title>/);
const missing = await app.request('/does-not-exist');
assert.equal(missing.status, 404); // request() returns negative statuses.
});
No sandbox
Loading a trusted app config module and running its setup / extensions can open
whatever connections the app config selects — a database, Valkey, or any other
service. close() tears the app down (services and the extension registry are
fresh per call), but there is no automatic database rollback, no global
module reset, and no cookie jar. Point each test at isolated fixtures and seeds
it owns; cookies, sessions, and other state persist only when the test sets them
explicitly.
The test surface also does not exercise Socket.IO or any native broadcast-adapter transport, because no transport is attached in-process. Core HTTP producer errors remain sanitized, and the testing helpers add no JSON CLI flags.
The native test suite
The generated starter's npm test is the default, browser-free path. It runs
tsc -p tsconfig.json (the server project) and then
node scripts/run-tests.mjs, which collects only compiled *.test.js files
under dist/test/ (never a Playwright *.spec.js) and forwards extra flags to
node --test.
Tests use the built-in node:test runner and native node:assert/strict — no
third-party framework.
npm test
npm run test:report # spec reporter on stdout + JUnit XML at test-results/junit.xml
npm run test:report runs the same tests with a spec reporter on stdout plus a
JUnit XML report at test-results/junit.xml; --enable-source-maps maps failure
stacks back to the original TypeScript source locations. npm run check remains
the compiler/client/static-build check (typecheck then build); it does not
execute the native app test suite.
The generated test/app.test.ts cases are shared by humans and AI agents: plain,
readable node:test examples that document server behavior both audiences can
extend.
Browser tests (optional)
npm run test:browser runs npm run build && playwright test. It is an optional
developer/AI UI tool, never part of the default npm test path:
@playwright/test is a starter devDependency, and there is no automatic
browser download — set JSAILS_BROWSER_PATH to an installed Chrome/Chromium or
run npx playwright install chromium yourself.
JSAILS_BROWSER_PATH=/path/to/chrome npm run test:browser
Playwright starts the already-built app on an isolated local server
(127.0.0.1:4173 by default; override with JSAILS_TEST_PORT) and never reuses
an existing service. Screenshots and traces require a browser. Failure
screenshots are attached to the test; traces are opt-in only with
JSAILS_TRACE=1. The JSON report lands in test-results/results.json and the
HTML report in playwright-report/. Both directories are git-ignored because
reports, screenshots, and traces can embed private UI, console, and network data.
The generated browser specs cover the counter island, the native <dialog>,
Home/About soft navigation, adding a task through the task-list backend action,
rejecting a blank task (422), a mid-edit in-flight navigation, and the native
opt-out / modified-click cases.
End-to-end verification
Two opt-in harnesses exercise the framework beyond unit tests. Neither is
triggered by a CLI command, and neither is part of check/test.
npm run verify:starter # pack + create --install + check + native tests + browser QA
npm run verify:starter -- --skip-browser
npm run verify:integrations # env-gated live-service harness
npm run verify:starter builds the framework, npm packs it, invokes the real
jsails create --install with a file: tarball dependency into a temp fixture,
imports the packaged createStarterFiles, runs the fixture's check,
validates the exported out/, then runs the fixture's test:report native suite
(a pass plus an expected-failure negative proof) and, in the default full
run, drives browser QA against jsails serve, a pure static serve of out/, and
a live jsails dev run. --skip-browser runs that same
pack/install/check/native-test pipeline but records each of the five browser
stages as an explicit skipped — never run, never probed — and launches 0
browsers.
npm run verify:integrations is opt-in and env-gated: it exercises the four
external-service seams (database, jobs, broadcast, Docker) against real services
the operator points at via JSAILS_TEST_* variables. Every stage skips cleanly
when its variables are absent (the run exits 0), and a stage that is enabled but
then fails exits non-zero. It requires an already-built dist/.
Next steps
- Deploy — ship the app you just tested.