Testing miniapps

Use small typed ports for unit tests. Use the Miniapp Test Lab when a test needs the real miniapp frame, lifecycle, permissions, or platform services.

Unit-test platform code

Importing the live sdk object is safe, but reading a property before the host installs a capability session throws. Keep host access at the surface boundary and pass a narrow typed port into business logic.

import type { MiniAppPlatformApi } from '@theaiplatform/miniapp-sdk/sdk';

export type ChannelReader = Pick<MiniAppPlatformApi['channels'], 'list'>;

export async function visibleChannelNames(
  channels: ChannelReader,
  workspaceId: string,
): Promise<string[]> {
  const result = await channels.list({ workspaceId });
  return result.rooms
    .filter((room) => !room.archived)
    .map((room) => room.title ?? room.roomId);
}

Pass sdk.channels at the surface boundary and a plain typed double in the unit test. Apply the same pattern to storage, projects, chat, or another SDK capability.

Add a host E2E suite

Pin the SDK and install the runner versions accepted by The AI Platform:

pnpm add -D @theaiplatform/miniapp-sdk@0.5.3 \
  @rstest/core@0.11.5 \
  @rstest/playwright@0.11.5 \
  playwright@^1.61.0

Scaffold the descriptor, deterministic Rstest configuration, dedicated TypeScript configuration, fixtures, and provenance smoke test:

pnpm exec tap-miniapp-test scaffold

The generated schema-v2 tap.test.json has one profile and positive matrix row for each declared surface and target. New suites use tests/e2e/**/*.tap.ts, which keeps host-driven cases out of an application's ordinary unit-test discovery. The generated Rstest configuration receives that exact effective testMatch; changing descriptor discovery no longer requires a second, independent glob edit. In Surface mode, the generated smoke test resets the fixture before mounting, which restores one-shot HTTP scripts and mutable fixture state. It then requires a visible, non-empty #tap-root with no lifecycle error. Add an app-specific loaded-state assertion as well: structural checks cannot recognize an app-owned error shell. Positive scaffold profiles capture screenshots with always; denied and error profiles default to failure-only.

If a surface-target cell has more than one allowed row, suffix exactly one matrix entry ID with -positive. This distinguishes the loaded capability journey from scripted HTTP and transport-error recovery rows without denying the host actions those recovery cases must reach.

Profiles accept default, all-denied, read-only, http-denied, or deny:<action-id> as permissionScenario. Keep deniedActions as the exact corresponding value: [], ["*"], ["do:*"], ["network.request"], or the one named action. synthetic:* values are host-internal diagnostics and are rejected by doctor before Test Lab.

Add these scripts so the same checks are easy to find locally and in CI:

{
  "scripts": {
    "test:tap": "rstest run --config ./rstest.tap.config.ts",
    "test:tap:list": "rstest list --config ./rstest.tap.config.ts",
    "typecheck:tap": "tsc --project ./tsconfig.tap-test.json --noEmit"
  }
}

tap-miniapp-test doctor requires direct compatible runner dependencies and an exact SDK pin. It warns when these scripts are absent.

Import the host adapter in tests that need The AI Platform:

import { expect, test } from '@theaiplatform/miniapp-sdk/testing/rstest';

test('creates a note through the mounted miniapp', async ({ surface, tap }) => {
  await surface.getByTestId('notes-new-btn').click();
  await surface.getByLabel('Title').fill('Release checklist');
  await surface.getByTestId('notes-save-btn').click();

  await expect(surface.getByText('Release checklist')).toBeVisible();
  expect(tap.permissionScenario).toBe('default');
});

The adapter keeps Rstest's assertions, hooks, filtering, and reporting. It replaces the Playwright browser fixtures with these host-owned fixtures:

FixtureUse
surfaceLocate elements inside the authorized miniapp iframe. Prefer this for miniapp behavior.
pageInspect the dedicated test host or full live app shell.
tapRead exact run provenance and use bounded host controls such as fixture reset or declared host events.

The adapter requires a session created by the Test Lab. Keep ordinary unit and browser-mode suites in normal CI; run host E2E tests from The AI Platform.

Test Lab handles an authorized sdk.notifications.show call with a deterministic fake. It returns { disposition: 'shown' } without dispatching an operating-system notification. Denied permission scenarios still reject at the ordinary host authorization boundary, so cover both the allowed result and your denied-state UI.

Descriptor fixture files and tap.fixture.seed() or tap.fixture.http.script() payloads can use the reserved authoring tokens tap-fixture-workspace-v1 and tap-fixture-channel-v1 wherever a string value must refer to the selected Test Lab scope. Before the fixture operation is applied, the host replaces every occurrence, including occurrences inside storage values, presence state, VFS paths, HTTP URLs, headers, and body text, with the run's actual workspace or channel ID. The bound payload is validated again, and reset restores the same bound base state. These tokens are scope references, so do not use either literal as ordinary fixture content or as a JSON object key.

For channel-role behavior, seed the exact access fixture before exercising the surface:

await tap.fixture.seed({
  channels: [
    {
      roomId: tap.channelId,
      title: 'Fixture channel',
      access: {
        isParticipant: true,
        capabilities: ['read', 'write', 'manage'],
      },
    },
  ],
});

When access is omitted, Surface fixture mode defaults to a participating channel with read and write. Access is included in runtime fixture snapshots and their digest, so tap.fixture.reset() restores the declared role exactly. The session-level tap.fixtureDigest instead attests the immutable descriptor fixture inputs selected for the run. Compare mutation, snapshot, and reset receipt digests with other runtime realm digests, not directly with that descriptor-input digest.

To verify retry and error UI without relying on a live endpoint, script a bounded transport failure before the miniapp makes its exact sdk.http request:

import {
  createTapMiniappTestTransportError,
  expect,
  test,
} from '@theaiplatform/miniapp-sdk/testing/rstest';

test('shows the unavailable state', async ({ surface, tap }) => {
  await tap.fixture.http.script({
    request: {
      method: 'GET',
      url: 'https://api.example.test/projects',
    },
    transportError: createTapMiniappTestTransportError({
      code: 'connection_failed',
      message: 'The fixture origin is unavailable.',
    }),
  });

  await surface.getByRole('button', { name: 'Load projects' }).click();
  await expect(
    surface.getByText('The fixture origin is unavailable.'),
  ).toBeVisible();
});

An HTTP fixture supplies exactly one response or transportError. A transport error represents failure before a response exists, preserves its bounded code as MiniAppHostActionError.code, and consumes the exact script once.

Before connecting the project, validate the declaration, list cases, and inspect the expanded capability matrix:

pnpm exec tap-miniapp-test doctor
pnpm exec tap-miniapp-test list
pnpm exec tap-miniapp-test matrix
pnpm exec tap-miniapp-test scaffold --check

Matrix capabilities are preflight declarations and coverage requirements, not proof that behavior passed. Only a completed Test Lab run proves the selected surface, profile, and cases executed. Live TAP rows additionally require an authenticated, dedicated host session and should stay separate from fixture-backed surface rows.

Connect the project

  1. Open the channel that owns the miniapp source.
  2. On desktop, open Apps > Marketplace > Installed. On mobile, open Workspace Settings > Miniapps > Installed. Select the installed package.
  3. Open Tests.
  4. Enter the source root and emitted package root relative to this channel's virtual file system.
  5. Select Connect test project.

The emitted root must be the exact artifact installed for the selected package. The platform hashes the source and test bundle when it creates the binding, then checks both hashes again before discovery and execution. If the source changes, reconnect to create a new provenance snapshot.

Choose an execution mode

Surface fixture

Surface mode is the fast, fixture-backed default. It mounts the emitted package in the production frame host with versioned workspace, channel, storage, presence, and permission fixtures. Use it for most SDK, lifecycle, and permission tests.

sdk.http.request is the exception: when the run lists the request's exact origin, TAP sends a real bounded HTTP(S) request through the native transport. Surface mode never reads the host credential vault. A profile may declare httpCredentialFixtures for its existing credentialSlots; at launch, Test Lab binds each declared slot to the selected run alias and exposes only the declared type, display name, and bounded non-secret metadata through sdk.credentials.listHttp. Omitted declarations remain invisible. Fixture metadata uses the vault's exact public shape: bearer {}, Basic { username }, header auth { header_name }, or API key { placement, parameter_name }. Secret and extra keys are rejected. Request use still requires credentials.use, the credentials effect, an exact run-owned alias, and an explicit fixture declaration for that alias. The http-denied scenario blocks sdk.http.request even when an origin was entered.

The SDK HTTP origin list is not a browser-wide network sandbox. The Rstest adapter observes HTTP and WebSocket traffic and emits sanitized diagnostics for unexpected origins, but it does not intercept browser requests. Host-mediated sdk.http.request still enforces its declared capability policy. Run every Test Lab suite only from source and dependencies you trust. The visible Debug action opens the same fixture-backed surface in a dedicated test window.

Live TAP

Live mode starts a separate automation-enabled The AI Platform process and uses real services in the selected channel. Use it only when deterministic fixtures cannot represent the journey.

The profile descriptor spells this mode "live". After the SDK attaches to the dedicated process, the Rstest fixture projects the canonical runtime value "live-tap":

import { expect, test } from '@theaiplatform/miniapp-sdk/testing/rstest';

test('runs in Live TAP', ({ tap }) => {
  expect(tap.mode).toBe('live-tap' satisfies typeof tap.mode);
});

Live test code is trusted local code running as you. It controls the complete dedicated test window and may inspect or change real channel state. Effect grants constrain miniapp host actions; they do not sandbox Playwright code or arbitrary browser networking.

Package-declared native services require a Live TAP profile and a separate, session-only opt-in. Follow Local services for the exact nativeServices, action, and effect declarations.

Before a live run:

  • review each exact platform action in Real effect grants;
  • list only the exact origins required by host-mediated sdk.http.request;
  • list only credential aliases required by Live TAP SDK requests; and
  • confirm that the selected channel can tolerate the side effects.

The confirmation is single-use and bound to the development binding, channel, actor, and exact effect set. The dedicated browser profile and authenticated CDP broker are destroyed when the run finishes or is canceled. The everyday app process never exposes its debugging port.

Run and debug

  • Run all runs the discovered suite headlessly.
  • Run selected runs the checked case IDs.
  • Run affected passes the listed source paths through Rstest's related-test selection.
  • Debug selected runs one selected case in a visible dedicated window.

The Test Lab shows ordered state, normalized counts, infrastructure failures, and channel-backed history. An assertion failure is failed; a runner, host, browser, or evidence failure is infrastructure failed.

Evidence and specialist handoff

Every terminal run writes one canonical manifest under the creating channel's virtual file system:

artifacts/miniapp-tests/<package-id>/<run-id>/
  report.json
  report.md
  cases/<safe-task-id>/
    trace.zip
    screenshot.png
    artifact-summary.json
    debug.md
  run.json

run.json records identity, source and bundle digests, versions, policy, selection, result counts, artifact hashes, and relative paths. The runtime writes it last as the canonical index; do not infer run evidence by scanning filenames. Trace and screenshot files are present only when the selected artifact policy captures them.

Choose Ask specialist to place a bounded diagnostic request in the same channel's draft. The request points to report.md, cases/<safe-task-id>/artifact-summary.json, and the corresponding debug.md. It deliberately excludes trace.zip from model context.

To curate evidence for a team, enter an authorized Knowledge Garden plot ID and choose Publish evidence. The default copy includes the run manifest, sanitized Markdown report, trace summaries, and debug notes. The channel VFS run remains the source of truth.

Package acceptance

Before pinning a production release, also verify that package CI:

  1. installs dependencies from the public npm registry in a clean checkout;
  2. typechecks and runs the complete unit suite;
  3. builds every declared target;
  4. assembles and scans the complete package;
  5. validates the emitted descriptor against config-schema.json; and
  6. imports every declared expose from its emitted graph.

A passing local Test Lab run is developer evidence. It is not signed Marketplace verification.