@app-studio/qa-studio
Debug Studio and browser adapters for two explicit contracts.
The CLI's qa demos run uses this same API renderer and canonical browser
compiler/runner to generate Chromium screenshots/video and complete reports
automatically. See Automatic demos for incremental
selection and temporary provenance-checked proof; /playwright also exports runScenarioCase.
/api exports the framework-free real HTTP runner and mountApiStudio; /react
also exports ApiStudio. The workbench edits requests/checks, shows redacted
evidence, resumes from native DB checkpoints and records a selected tab/window.
The CLI serves it without an application browser build. See
API Studio and pnpm api:demo in the workspace.
State Fixture v3
A render-only component or screen state:
{
"schemaVersion": 3,
"kind": "state-fixture",
"screen": "content/notes:NotesListPage",
"outcome": "worst",
"states": [{
"id": "content/notes:empty",
"summary": "A new workspace has no notes yet",
"routes": [{ "match": "/notes", "method": "GET", "answer": { "items": [] } }],
"persona": "connected",
"viewport": { "preset": "desktop" }
}]
}
The same fixture feeds component tests, Debug Studio, and qa state run --id. It contains no product actions and makes no end-to-end claim.
import { installState, statesFrom, type StateFixtureFile } from '@app-studio/qa-studio';
The React host supplies StateEntry[], renderState, persona setup, and optional store bootstrap. Deep links use ?state=<id> and the rendered boundary exposes data-state=<id>.
Browser adapters add qa-studio=agent by default. In that presentation,
Debug Studio mounts only the selected application pane: no toolbar, state
picker, catalog navigation, or human seed warning. Opening the Studio directly
without that parameter keeps the full interactive shell. An application-owned
adapter can opt back into it with studioPresentation: 'interactive'.
React Native and other non-DOM hosts
@app-studio/qa-studio/fetch exports only installState, statesFrom,
validateStateFixtureFile and the State Fixture types. Nothing it reaches
touches the DOM or a Node built-in at import, so Metro can bundle it:
import { installState, statesFrom } from '@app-studio/qa-studio/fetch';
import notes from './states/notes.state.json';
const [populated] = statesFrom(notes);
installState(populated); // replaces globalThis.fetch until restore()
node scripts/check-portable-entry.mjs dist/fetch.js (pnpm check:portable)
walks the built entry's import graph and fails on Node built-ins, DOM globals
at module scope, or a DOM driver / evidence module.
State Fixtures over local HTTP
The Node entrypoint can answer the same State Fixture through a real loopback
listener. Each world owns a fresh fixture and a private installState fetch
target; it never replaces global fetch or falls back to a real API.
import { randomUUID } from 'node:crypto';
import { createStateFixtureHttpServer } from '@app-studio/qa-studio/node';
import { createFixture } from './fixtures.js'; // Host-owned, browser-free factory.
const token = randomUUID();
const world = await createStateFixtureHttpServer({
createFixture,
browserOrigin: 'http://127.0.0.1:4003', // Exact browser origin of this run.
token,
tokenHeader: 'x-qa-world-token',
});
try {
// Host configures its generated SDK with world.origin and the transport header.
// Keep this world alive across navigation, reloads and new browser sessions.
} finally {
await world.close();
}
tokenHeader defaults to authorization with Bearer <token>. Prefer
x-qa-world-token when the host supports anonymous/login states: add it centrally
to SDK requests without changing business Authorization or opening a fake user
session. The fixture's persona still determines session setup. This token isolates
the mock transport; it does not simulate an ACL or prove real authentication.
Set StudioConfig.mockTransport: 'http', or render a keyed
StateFrame with dataMode="mock" mockTransport="http". The host must activate
the matching world and await readiness before any session reads or screen
mount, using its existing preparation/bootstrap seam. Changing the picker needs a
new matching world; it cannot keep another fixture's routes. prepare(state) is
awaited before mounting a StateFrame's children. HTTP mode installs no in-page
interceptor and Debug Studio skips its unused seed capture. The boundary exposes
data-mode="mock", data-mock-transport="http" and
data-qa-data-source="state-fixture-http"; live mode keeps data-mode="live"
and data-qa-data-source="real-http".
The returned world exposes origin, fixtureId, requests, observations,
unmatched and diagnostics metadata (method, path, status,
source and failure code; no headers or bodies). reset() drains current requests
and invokes the factory again for the same fixture ID. Factories must return
fresh mutable state. Reloading must not call reset. close() stops acceptance,
closes listener/connections and restores the private target. The host owns its
browser/Vite processes, temporary files, signal handling and bounded teardown.
Only GET/POST/PATCH/PUT/DELETE and JSON bodies up to 64 KiB are supported; HEAD,
multipart/uploads, SSE and WebSockets are outside this transport. Reuse existing
adapters for those capabilities or report the gap. CORS allows the exact loopback
browser origin and declared transport/SDK headers. Invalid JSON, wrong origins,
missing tokens and undeclared routes fail explicitly; unmapped always fails,
regardless of the fixture default. Responses carry x-qa-data-source and
x-qa-state-fixture. Production Node execution is refused. The host must also
guard its dev browser configuration against production builds and prevent other
network egress. Node factories must not import React story barrels.
This API is a source addition: verify the actual installed /node exports and
React types before adopting it. A version string alone does not prove the addition
is available; consumers need an authorized package/commit/artifact containing it.
Original App Studio feature flows
The original specs/features/*.json files are executable. The core compiler
joins feature.userExperience to optional specs/forms/*.json, separates each
if condition from the nominal path, and materializes a portable flow for
Playwright or wdio/Appium:
import { defineScenarioSuite } from '@app-studio/qa-studio/playwright';
defineScenarioSuite({
test,
expect,
rootDir: process.cwd(),
pattern: 'specs/features/*.json',
forms: 'specs/forms/*.json',
prepare: async ({ scenario }) => prepareFeatureWorld(scenario),
});
User actions operate the control named by the original eventId (or the
form's exact data-testid). App actions assert routes, real requests and
modal state. Explicit Scenario v3 files remain supported for non-generated
repositories, but App Studio projects do not copy their flow into one.
QA_BROWSER_PROFILE=fast runs the same compiled feature flow and writes its machine-readable
report without checkpoint media by default. Lightpanda hosts can also request a
final diagnostic PNG with qa scenario run --engine lightpanda --profile fast --screenshots.
The default proof profile keeps the strict
visual evidence. Browser engine selection remains application-owned and is
addressed through qa scenario run --engine ....
Targeted state browser adapter
defineStatesSuite({
test,
expect,
rootDir: process.cwd(),
pattern: 'src/features/**/states/*.state.json',
});
Lightpanda hosts should use @app-studio/qa-studio/puppeteer. Its
runPuppeteerScenarioSuite and runPuppeteerStateSuite functions preserve the
same JSON graph, request evidence and verdicts without Playwright Test's
Chromium-only context emulation. The application still owns server startup,
the CDP process, and its State Fixture preparation callback.
Both suites install Lightpanda CSSOM compatibility before preparation or navigation
when the page exposes Puppeteer's evaluateOnNewDocument. Minimal custom adapters
without that capability remain supported and own their document initialization.
The repairs make indexed stylesheets iterable and supply missing media-rule grouping
operations needed by libraries such as Stitches. Native implementations stay intact;
this enables DOM execution, not CSS layout or visual fidelity. Request interception
and application-specific setup remain host-owned.
Custom runners can use the same preparation in their shared page factory:
import { prepareLightpandaPage } from '@app-studio/qa-studio/puppeteer';
async function createPage() {
const page = await browser.newPage();
await prepareLightpandaPage(page); // Before the first goto, for states and scenarios.
return page;
}
Compatibility stays installed for the document lifetime, including later lazy imports and reloads. It is independent of mock-request adoption or release.
Both Puppeteer suites support screenshots: true, or the CLI's
QA_BROWSER_SCREENSHOTS=true, to capture the current page before it closes,
including failed runs. They write screenshot.png and screenshot.json per case,
and emit a JSON line with absolute paths, case status and fidelity: 'markdown'.
These are Lightpanda text-layout diagnostics; Chromium remains the engine for
strict visual proof. Known historical placeholder PNGs are refused with an
upgrade instruction. Capture errors fail the requested run and preserve its
original product error.
The suites accept an explicit outputDir, then QA_SCENARIO_OUTPUT_DIR or
QA_STATE_OUTPUT_DIR, then <rootDir>/test-results/qa-scenarios or
<rootDir>/test-results/qa-states. The CLI derives those environment paths from
scenario.evidence and state.evidence in the bindings. A custom page factory
only needs Puppeteer's native screenshot method; minimal adapters can omit it
when screenshots are disabled. Custom runners can capture a PNG directly:
import { captureLightpandaScreenshot } from '@app-studio/qa-studio/puppeteer';
const path = await captureLightpandaScreenshot(page, 'artifacts/current-page.png');
QA_STATE_ID narrows execution to one fixture. The CLI also sends
QA_BROWSER_HEADLESS=true and QA_STUDIO_PRESENTATION=agent. The adapter waits
for the real rendered boundary, checks non-empty DOM unless rendersNothing is
declared, collects page errors, and reports fabricated API answers. It adds no
dwell time, captions, or video requirement.
Agent-driven page debugging (local 0.11.4-rc.3)
BrowserDebugSession from @app-studio/qa-studio/puppeteer joins an existing
scenario page and exposes session, observe, reproduce, inspect, control and
cursor-based recording operations. The application host retains process
startup and server Inspector orchestration. See
examples/agent-debug for the six-tool
MCP integration, ownership rules, source-map verification and native validation.
The native package must contain a verified debugger build; the local host RC
can be selected through a package override without an executable environment
variable. Older engines can report pausing as unavailable. Inspection defaults to side-effect checks; code execution requires
explicit opt-in. Targeted captures pause and resume the live page and affect
its timing; they do not provide deterministic replay.
exposeBrowserDebugPage(page, {pageId, baseUrl, build, expiresMs?}) exports the
host's existing, already navigated Page through a one-client, opaque loopback
WebSocket capability. It returns {pageId, baseUrl, pageDebugEndpoint, close};
never log the endpoint or publish it in diagnostics. The client first sends
QA.describePage with the exact pageId, origin and exclusiveControl: true.
The additive response scriptInventory.supported advertises compact-v1.
Only when advertised, a client can opt in through qaScriptInventory: 'compact-v1'
on its existing Debugger.enable request. The relay strips this extension before
native CDP and acknowledges the selected mode in that response. Negotiated clients
receive bounded QA.scriptParsed metadata instead of full Debugger.scriptParsed
events for this lease; legacy clients and direct native CDP retain standard events. Inline map declarations become SHA256/UTF8
length metadata (sourceMapDeferred), with no retained map body. source(id)
retrieves only that generated source, recovers its final line/block
sourceMappingURL declaration and verifies both digest and length. Missing,
stale or overbound declarations refuse resolution; loadedScripts() remains
lightweight. The inventory remains capped at1024, retaining mapped/application
scripts ahead of helpers, with explicit dropped/unavailable counts in recording.
Generated source is limited to2MiB and its encoded relay response to3MiB;
oversized source results fail the operation without closing debug control.
The relay permits only SDK debugger commands with side effects forbidden and
QA.pageAction ({action: 'click', selector} or {action: 'navigate', path} on
the registered origin). Close the relay in the host's finally block. Relay
cleanup releases only its debugging resources and never closes the page or
browser. Initial capability lifetime defaults to five minutes; accepted clients
cannot reconnect. Native request IDs and SDK action IDs do not imply server
correlation.
The SDK records bounded incident events from real Runtime console
warnings/errors and exceptions, and Page dialogs. onEvent(listener) returns an
unsubscribe function; it does not wake a background model. Remote values are
projected from protocol primitives/descriptions without evaluation or getters.
Exception policy is isolate-global: exceptions(policy) requires the actual
owner's exceptionPolicyOwner: {baseline, exclusiveIsolate: true} contract.
Attachment does not set a policy. Exact exception and promiseRejection pause
reasons share exception ownership/incident handling under the same acknowledged
policy lease; the raw reason and actual uncaught flag are retained. exceptionStatus().supportedModes comes from
actual native capabilities; legacy engines do not implicitly support uncaught
prediction. The corrected native callback boundary declares all three modes. Acknowledged leases expire after five minutes;
cleanup restores the declared baseline before releasing an owned exception pause.
Unknown external baselines refuse mutation; restoration/uncertainty is explicit
in exceptionStatus(). The relay can return bounded actual owner Page state
from an afterDetach callback through its typed release operation.
reproduce, control, and waitForAction accept an optional host AbortSignal
for cancelling their waits. Cancellation removes wait listeners/timers; it does
not cancel an already dispatched page action, resume a pause, or stop the session.