Integration Test Suite (Tier 2)
Playwright-driven tests that dispatch directly into the real AppModule Nest.js DI container — built in-process via @nestjs/testing's Test.createTestingModule() — with the AWS SDK mocked and PulumiService substituted for an in-memory stub at the DI seam. There is no HTTP server, no Vite build/preview, and no BrowserWindow: everything runs in a single Node process. The goal is to validate controller-level business logic (permission checks, stack-output resolution, ECS command orchestration, error propagation) against the exact provider wiring the Electron IPC transport uses at runtime, without spinning up real AWS infrastructure or a real Pulumi engine.
How to Run
# Build the server, then run the integration Playwright suite
npm run app:test:integration
This command (from the repo root):
- Builds
@hyveon/desktop-mainviatsc(producesdist/, which the harness deep-imports). - Runs
playwright test --config playwright.integration.config.tsfrom@hyveon/web.
playwright.integration.config.ts has no webServer and no projects entries — each spec builds its own ipc harness (a fresh AppModule application context) via the ipc fixture, so there's nothing to boot ahead of time.
Architecture
Playwright test process (single Node process, no HTTP server, no BrowserWindow)
├── ipc (IpcHarness) ─────────────────────────── Test.createTestingModule({ imports: [AppModule] }).overrideProvider(PulumiService).useValue(pulumiStub).compile()
│ ├── dispatch(Controller, 'method', ...) ── invokes the controller instance directly
│ └── get(Provider) ──────────────────────── resolves a provider (e.g. PulumiService) straight from the container
├── ipc.mocks.pulumi (PulumiServiceStub) ──────── the DI-substituted PulumiService — script*() setters control stack outputs, preview/apply/destroy chunks+result
├── serverMocks (ServerMocks) ────────────────── pushes into the shared MockStore singleton
│ ├── aws-sdk-client-mock (ECSClient prototype patched) ── installEcsMock() reads from MockStore
│ ├── aws-sdk-client-mock (EC2Client prototype patched) ─── installEc2Mock() reads DescribeNetworkInterfaces from MockStore
│ └── aws-sdk-client-mock (SchedulerClient prototype patched) ── installSchedulerMock(), fixed success responses
├── runRecordMockStore ────────────────────────── stateful pk=RUN / pk=LOCK item store
│ └── aws-sdk-client-mock (DynamoDBDocumentClient prototype patched) ── installRunRecordDynamoMock()
└── remoteFileStoreMockStore ───────────────────── single versioned configuration-object store
└── aws-sdk-client-mock (S3Client prototype patched) ── installRemoteFileStoreMock()
Key Files
| File | Purpose |
|---|---|
app/packages/desktop-main/src/test-mocks/mock-store.ts | In-process MockStore singleton with per-command FIFO queues (ListTasks, DescribeTasks, RunTask, StopTask, and EC2 DescribeNetworkInterfaces). |
app/packages/desktop-main/src/test-mocks/ecs-mock.ts | Installs aws-sdk-client-mock interceptors on ECSClient. The four task commands read MockStore; DescribeTaskDefinition, RegisterTaskDefinition, DeregisterTaskDefinition and TagResource (the file-manager helper launch) return fixed responses with no queue — see Fixed (unqueued) handlers. |
app/packages/desktop-main/src/test-mocks/ec2-mock.ts | installEc2Mock() — interceptors on EC2Client for the commands the file-manager helper issues. DescribeNetworkInterfaces reads the MockStore queue; the security-group commands (AuthorizeSecurityGroupIngress, DescribeSecurityGroupRules, RevokeSecurityGroupIngress) always succeed, with no rules present. |
app/packages/desktop-main/src/test-mocks/scheduler-mock.ts | installSchedulerMock() — interceptors on SchedulerClient so the helper's auto-stop schedule calls (CreateSchedule, DeleteSchedule) resolve {} without reaching AWS. |
app/packages/desktop-main/src/test-mocks/run-record-mock.ts | Installs aws-sdk-client-mock interceptors on DynamoDBDocumentClient, backed by the stateful runRecordMockStore singleton (pk = RUN run records + the single pk = LOCK apply-lock item) — see DynamoDB Run-Record Mock below. |
app/packages/desktop-main/src/test-mocks/remote-file-store-mock.ts | Installs aws-sdk-client-mock interceptors on S3Client, backed by the stateful remoteFileStoreMockStore singleton (a single versioned configuration object, keyed by CONFIGURATION_OBJECT_KEY) — see Configuration-Bucket S3 Mock below. |
app/packages/desktop-main/src/test-mocks/pulumi-mock.ts | PulumiServiceStub — the class substituted for the real PulumiService at the DI seam. Exposes script* setters (scriptStackOutputs, scriptPreview/scriptApply/scriptDestroy, scriptOperationInFlight, scriptDestroyToken) plus reset() — see PulumiService DI-Seam Stub below. |
app/packages/web/e2e/fixtures/ipc-harness.ts | Builds the in-process IPC test harness (createIpcHarness()) via Test.createTestingModule({ imports: [AppModule] }).overrideProvider(PulumiService).useValue(pulumiStub).compile(), deep-importing @hyveon/desktop-main's compiled dist/, and dispatches directly to controller methods. Installs the ECS, EC2 and Scheduler mocks (plus the run-record and S3 ones) before building the container. Also exposes get(Provider) to resolve a provider (e.g. PulumiService) directly from the container, and mocks.pulumi to reach the scriptable stub. mocks.push* (including pushDescribeNetworkInterfaces) are thin wrappers over MockStore. |
app/packages/web/e2e/fixtures/server-mocks.ts | ServerMocks class + extended test with serverMocks and ipc fixtures. Has one push* method per queue, including pushDescribeNetworkInterfaces for the EC2 ENI lookup. |
app/packages/web/e2e/fixtures/stack-outputs.fixture.ts | Exports DEFAULT_STACK_OUTPUTS: StackOutputs — a synthetic, fully-deployed stack-outputs value (minecraft + valheim, us-east-1, test.example.com) that specs script onto ipc.mocks.pulumi via scriptStackOutputs(). Replaces the deleted tfstate.fixture.json; re-exported from integration-specs/index.ts. Includes efsVolumeAccessPoints (game → volume → access point) alongside the first-volume-only efsAccessPoints. |
app/packages/desktop-main/src/testing/fake-filebrowser-server.ts | startFakeFileBrowser() — an in-process HTTPS server standing in for the FileBrowser helper, using a real ephemeral cert. It speaks the FileBrowser endpoints FileBrowserClient uses: login, directory create (missing parents included), upload (override=false on an existing file answers 409), listing and single-entry stat, raw download, zip archive, rename (the destination is query-decoded a second time, as the real server does) and delete. Options let a test reject an upload with a status code, drop the socket on a given attempt (onUpload), pre-seed files already on the "volume" (seed), set their modification times (seedModified) or give them exact content (seedContent). |
app/packages/desktop-main/src/testing/fake-volume-backend.ts | fakeBackend() — a scriptable in-memory VolumeFileBackend that records writes and serves listings from them (a directory with no recorded files rejects with not-found). stat, readStream, move and remove reject until a test overrides them; any method can be overridden. Used by unit specs that don't need a socket. |
app/packages/shared/src/testing/secretFields.ts | findSecretFields(value) — the vitest-free core of the no-secret-fields check: it walks a result and returns one description (with its path, e.g. result.status.helperPassword (key)) per key or string value that looks like a credential, token, certificate, fingerprint, IP address or URL (matched against password, credential, token, fingerprint, cert, http(s):// and dotted-quad patterns), or [] when clean. Deep-import it as @hyveon/shared/testing/secretFields (a package.json subpath export, aliased in app/vitest.config.ts); it is deliberately absent from the @hyveon/shared barrel. Because it has no test-runner dependency, Playwright specs can use it directly: assert expect(findSecretFields(responses)).toEqual([]). Covered by secretFields.test.ts alongside it. |
app/packages/desktop-main/src/testing/no-secret-fields.ts | expectNoSecretFields(value) — the vitest wrapper around findSecretFields (expect(findSecretFields(value)).toEqual([])), a unit-test assertion that fails if an IPC result carries any of those patterns. Used by volume-files.controller.test.ts and VolumeFilesService.test.ts to prove helper connection details never cross IPC. Importing it pulls in vitest, so Playwright specs use findSecretFields instead. |
app/packages/desktop-main/src/services/FolderUpload.integration.test.ts | Vitest spec that runs FolderUploadService through a real HelperSessionRegistry and the real FileBrowserClientFactory (TLS pinning included) against the fake server, so browsing and upload share one helper session as in production; the AWS-facing collaborators (FileManagerService, PublicIpService, EcsService) and the config services (ConfigService, DeploymentConfigService) are stubbed, and the registry's timings are overridden (helperPort points at the fake server, pollMs = 1). |
app/packages/web/e2e/fixtures/hyveon-http-bridge.ts | Besides the HTTP bridge, carries an in-page folderUpload fake (folder upload has no HTTP route): it scans a canned folder and walks a run through starting-helper, uploading, verifying and done so e2e specs can drive the UI flow. Setting window.__e2eFolderConflicts before starting makes the run pause in awaiting-confirmation with a canned overwrite summary until the spec answers through confirmOverwrite. It also carries a volumeFiles fake (the volume file browser has no HTTP route either) that a spec seeds through window.__e2eVolumeFiles — see Tier-1 chromium: in-page bridge fakes. Every method's return value except cancelTransfer, onSession and onTransfer is appended to window.__e2eVolumeFilesResponses. The legacy files: entry (list/start/stop against /api/files/*) is gone along with the old file-manager modal. |
app/packages/web/playwright.integration.config.ts | Playwright config: testDir: e2e/integration-specs, workers: 1, no webServer, no projects. |
app/packages/web/e2e/integration-specs/ | All integration specs; import test/expect/DEFAULT_STACK_OUTPUTS from ./index.js, not @playwright/test. |
How Mock Responses Work
The in-process MockStore singleton holds separate FIFO queues for ListTasks, DescribeTasks, RunTask, StopTask, and EC2 DescribeNetworkInterfaces. When a queue is empty, the corresponding interceptor returns a safe default:
| Command | Default (empty queue) |
|---|---|
ListTasksCommand | { taskArns: [] } → game is stopped |
DescribeTasksCommand | { tasks: [] } |
RunTaskCommand | { tasks: [{ taskArn: 'arn:…/test-task-id' }], failures: [] } |
StopTaskCommand | {} |
DescribeNetworkInterfacesCommand (EC2) | { NetworkInterfaces: [] } → no ENI is returned, so Ec2Service.getPublicIp yields null |
Push a response before dispatching the controller call that will consume it:
await serverMocks.pushListTasks({
type: 'success',
data: { taskArns: ['arn:aws:ecs:us-east-1:123:task/test-cluster/abc'] },
});
await serverMocks.pushDescribeTasks({
type: 'success',
data: { tasks: [{ taskArn: '…', lastStatus: 'RUNNING' }] },
});
const status = await ipc.dispatch(GamesController, 'getStatus', 'minecraft');
Queue an ENI lookup the same way (ipc.mocks.pushDescribeNetworkInterfaces(...) on the harness, or serverMocks.pushDescribeNetworkInterfaces(...)); an error entry throws an error whose name is the given code (default ServiceException):
await serverMocks.pushDescribeNetworkInterfaces({
type: 'success',
data: { NetworkInterfaces: [{ NetworkInterfaceId: 'eni-helper1234', Association: { PublicIp: '198.51.100.4' } }] },
});
Push an error to test propagation:
await serverMocks.pushRunTask({
type: 'error',
code: 'AccessDeniedException',
message: 'User is not authorized to perform ecs:RunTask',
});
Fixed (unqueued) handlers
Some commands have a single canned success response and no queue, so a spec cannot vary them per call. They exist so the file-manager helper launch path runs end to end without AWS:
| Client | Command | Response |
|---|---|---|
| ECS | DescribeTaskDefinitionCommand | taskDefinition with cpu: '1024', memory: '2048' and an executionRoleArn |
| ECS | RegisterTaskDefinitionCommand | taskDefinition.taskDefinitionArn of the helper's throwaway task definition |
| ECS | DeregisterTaskDefinitionCommand, TagResourceCommand | {} |
| EC2 | AuthorizeSecurityGroupIngressCommand, RevokeSecurityGroupIngressCommand | {} |
| EC2 | DescribeSecurityGroupRules | { SecurityGroupRules: [] } |
| Scheduler | CreateScheduleCommand, DeleteScheduleCommand | {} |
Because these mocks patch a client prototype, a command with no registered handler returns undefined rather than failing. Add a handler in the matching test-mocks/*.ts file before relying on a new command from a spec. The three client mock files are listed in the AWS-SDK-import allowlist in app/eslint.config.js (ecs-mock.ts, ec2-mock.ts and scheduler-mock.ts among the test-mocks/ entries), so a new mock file that imports @aws-sdk/* needs an entry there too.
Spec Inventory
app/packages/web/e2e/integration-specs/ currently holds:
| Spec | What it tests |
|---|---|
can-run.spec.ts | canRun() (@hyveon/shared) enforces guild allowlisting, admin bypass, and per-game action grants, against config seeded through DiscordController's real IPC channels. |
config-service.spec.ts | EnvController.getEnv returns region + domain, and GamesController.listGames/listStatus return the game list, once ipc.mocks.pulumi is scripted with DEFAULT_STACK_OUTPUTS. |
diagnostics-export.spec.ts | Sets HYVEON_CONFIG_BUCKET itself (see Configuration-Bucket S3 Mock) and exercises the diagnostics-export path against the S3-backed configuration store. It avoids the native save dialog by replacing NativeDialogService.pickSavePath on the instance resolved from the Nest container, so the real DiagnosticsBundleService runs past the dialog. |
discord-config.spec.ts | DiscordController.getConfig never echoes the raw bot token or public key — only the redacted botTokenSet/publicKeySet booleans. |
error-propagation.spec.ts | AccessDeniedException from RunTaskCommand surfaces as { success: false, message: '…' } from GamesController.start. |
guided-iam.spec.ts | Dispatches the five wizard.guidedIam.* channels through the real, DI-resolved WizardController → GuidedIamService, covering template rendering, console-URL fallback, bootstrap-key intake, the full mint→verify→revoke rotation, and the delete-failed manual-revoke retry. |
helper-session.spec.ts | HelperSessionRegistry (resolved via ipc.get(...)) against the mocked ECS/EC2 clients: status() reports starting (with browseHeld: true) while a launched helper's ENI lookup keeps returning no public IP, and orphaned when ECS lists a helper task the registry does not own. Each test overrides PublicIpService.detect on the DI-resolved instance to return a fixed IP, and scripts the stack outputs with an empty fileBrowserCredentialSecretArn so the launch never reaches Secrets Manager. The starting test queues 200 polls of ECS/EC2 responses and shortens registry.timings so the readiness loop is observable. |
iac-apply-gates.spec.ts | 9 scenarios covering PulumiService.apply's gate stack (unapproved/expired/mismatched-hash/engine-version-mismatch/fresh-approved/missing-record/stale-artifact/config-moved/competing-applies), scripted via PulumiServiceStub.scriptApply({ failure: <real gate-error class> }) and asserted against IacController.apply's ack-shaping. |
iac-destroy-token.spec.ts | 6 scenarios covering IacController.destroy's confirmation-token gate (no-token/consumed/fresh/wrong-target/expired-or-superseded/concurrent). |
iac-plan.spec.ts | 3 scenarios: plan artifact/planHash persistence, the structured change summary, and the failed-preview-no-hash path. |
iac-run-records.spec.ts | 4 scenarios exercising RunRecordService directly via ipc.get(RunRecordService) — planHash-on-success, failed-still-persisted, inline-log, and retrievable-via-runs-list. This is the spec that actually drives the DynamoDB run-record mock (see DynamoDB Run-Record Mock). |
iac-streaming-ansi.spec.ts | ANSI escape sequences are preserved in both the live stream and a directly-persisted run log. |
pulumi-di-seam.spec.ts | Proves the DI substitution itself: a scripted, non-UUID-shaped mintDestroyConfirmationToken() value round-trips through IacController.mintDestroyToken, and ipc.get(PulumiService) is reference-equal to ipc.mocks.pulumi. |
stack-outputs.spec.ts | IacController.output (the iac.output channel) returns the scripted PulumiService.getStackOutputs() value verbatim, and degrades to null — not a throw — for a never-deployed stack. |
start-stop.spec.ts | GamesController.listGames/listStatus report STOPPED games on initial load; a game seeded as RUNNING via mocked ECS responses can be stopped. |
status-polling.spec.ts | Pushing RUNNING mock responses causes the next GamesController.listStatus dispatch to reflect the state change (the in-process analogue of the dashboard's poller). |
volume-outputs.spec.ts | The per-volume EFS access point map (efsVolumeAccessPoints) scripted into the PulumiService stub is returned intact by the real ConfigService.getStackOutputs(). |
support/iac-ctx.ts | Not a spec — makeFakeIacCtx(), a Playwright-appropriate reimplementation of iac.controller.test.ts's vitest-only makeCtx() (plain call-tracking arrays instead of vi.fn(), since these specs are typechecked and the vitest test file is not). Shared by the iac-* specs above. |
PulumiService DI-Seam Stub
The previous provisioning service (which shelled out to a real CLI binary, faked via a PATH-shimmed stub script) is gone — the migrate-iac-to-pulumi change replaced it with PulumiService, which drives the @pulumi/pulumi/automation API in-process. There is no PATH to shim any more, so the integration tier fakes it the way Nest testing intends: createIpcHarness() (ipc-harness.ts) builds the container with Test.createTestingModule({ imports: [AppModule] }).overrideProvider(PulumiService).useValue(pulumiStub), substituting a fresh PulumiServiceStub (app/packages/desktop-main/src/test-mocks/pulumi-mock.ts) for every consumer that injects PulumiService — ConfigService, IacController, IacRunsController, DriftService, and so on. This is also why the harness switched off NestFactory.createApplicationContext(): that API has no provider-override hook, and a TestingModule already extends NestApplicationContext (.get(), .close(), ...), so no separate "create an application" step is needed once it's compiled.
Reach the stub via ipc.mocks.pulumi (aliased as harness.mocks.pulumi in the type):
import { test, expect, DEFAULT_STACK_OUTPUTS } from './index.js';
import { IacController } from '@hyveon/desktop-main/dist/controllers/iac.controller.js';
test('should ...', async ({ ipc }) => {
ipc.mocks.pulumi.scriptStackOutputs(DEFAULT_STACK_OUTPUTS);
ipc.mocks.pulumi.scriptApply({ chunks: [{ stream: 'stdout', text: '...' }], result: { /* PulumiUpResult */ } });
const outputs = await ipc.dispatch(IacController, 'output', {});
});
Scripting surface (see the class's own TSDoc for the full contract):
| Setter | Scripts |
|---|---|
scriptStackOutputs(outputs | null) | getStackOutputs()'s next resolution — null models a never-deployed stack (the default). |
scriptOperationInFlight(op | null) | getOperationInFlight()'s next return value — null (default) means the workspace is free. |
scriptDestroyToken(token) | The token mintDestroyConfirmationToken() returns next. |
scriptPreview(run) / scriptApply(run) / scriptDestroy(run) | The { chunks?, result? } or { chunks?, failure? } an operation's async generator plays back — yields chunks in order, then either returns result or throws failure, mirroring how a real PulumiService operation settles. Takes effect for every subsequent call until re-scripted (not a one-shot FIFO queue). |
reset() | Restores every scripted response to its never-deployed/workspace-free/empty-run default. |
Un-scripted surface. initializeStack/resolveRollbackTarget/computeRollbackDiff/confirmRollback/clearStaleLock have no script* setter — rollback stayed explicitly out of scope for the Plan/Apply/Destroy/run-record coverage below, so they resolve fixed, harmless placeholder values. Plan/Apply/Destroy gating, ANSI-preservation, and run-record-persistence coverage is built: see iac-plan.spec.ts, iac-apply-gates.spec.ts, iac-destroy-token.spec.ts, iac-streaming-ansi.spec.ts, and iac-run-records.spec.ts above. Those gate errors (PulumiPlanNotApprovedError, RunLockHeldError, DestroyNotConfirmedError, etc.) are scripted straight onto PulumiServiceStub via { failure: <real gate-error class instance> } rather than needing dedicated setters, since the gate math itself is already unit-tested in PulumiService.apply.test.ts/.destroy.test.ts and is not re-derived at this tier.
createIpcHarness() builds a fresh PulumiServiceStub per harness (per Playwright test) — unlike mockStore/runRecordMockStore/remoteFileStoreMockStore, which are process-wide singletons reset between harnesses because aws-sdk-client-mock patches a shared client prototype, a fresh stub instance needs no cross-test reset.
DEFAULT_STACK_OUTPUTS (app/packages/web/e2e/fixtures/stack-outputs.fixture.ts) is the fixture most specs script — a synthetic, fully-deployed StackOutputs value with the same region/domain/game names/table names the deleted legacy stack-outputs fixture used, so every spec that asserted against the old fixture keeps asserting the same values, just read through PulumiService.getStackOutputs()'s stubbed return instead of a parsed local state file.
DynamoDB Run-Record Mock
app/packages/desktop-main/src/test-mocks/run-record-mock.ts installs aws-sdk-client-mock interceptors on the DynamoDBDocumentClient prototype (installRunRecordDynamoMock(), wired into createIpcHarness() alongside installEcsMock()), backed by the exported runRecordMockStore singleton. Unlike MockStore's FIFO queues, this is a genuinely stateful table: a run persisted through the real RunRecordService is retrievable by a later call in the same spec, exactly like production. iac-run-records.spec.ts drives this directly — it calls ipc.get(RunRecordService).persist(...) and .getByRunId(...) against the real, DI-resolved RunRecordService (bypassing PulumiService entirely, the same pattern pulumi-di-seam.spec.ts uses for PulumiService itself), so the mock is exercised, not inert.
pk = RUNitems —PutCommand/QueryCommandmirrorAwsRunRecordStore'sputRecord/getRecordByRunId/listRunsrequest shapes (upsert-by-sk, filter byrunId/before/status,Limit).pk = LOCK/sk = CURRENTitem — the single apply-lock itemRunService.createRun/releaseRunacquire/release viaacquireRunLock/releaseRunLock.PutCommand's conditional-put semantics (attribute_not_exists(pk) OR expiresAt < :now) are reproduced, throwingConditionalCheckFailedExceptionwhen another unexpired lock is held — the same exceptionAwsRunRecordStore.acquireRunLockcatches and converts toRunLockHeldError.runRecordMockStore.patchApprovedAt(runId, isoString)— directly overwrites a stored record'sapprovedAt, letting a spec simulate an approval minted outside the 15-minute apply window without fake timers (which don't reach a spawned child process, andPulumiService's Automation API calls do spawn one).- Reset per harness —
createIpcHarness()callsrunRecordMockStore.reset()before installing the mock, so no plan/apply/destroy record or apply lock leaks from one spec'sAppModulecontext into the next.
Since the mock patches DynamoDBDocumentClient's prototype globally, it also intercepts AuditService's DynamoDB traffic (harmless — audit items land in the same in-memory item list but are excluded from every runId-filtered query).
Configuration-Bucket S3 Mock
app/packages/desktop-main/src/test-mocks/remote-file-store-mock.ts installs aws-sdk-client-mock interceptors on the S3Client prototype (installRemoteFileStoreMock(), wired into createIpcHarness() alongside installEcsMock()/installRunRecordDynamoMock()), backed by the exported remoteFileStoreMockStore singleton — the configuration-bucket counterpart of the ECS/run-record mocks above. There is no local-file configuration fallback (see the migrate-iac-to-pulumi change's Phase 6), so DeploymentConfigService's read/write paths require a genuinely working RemoteFileStore to succeed against real AWS.
- A single versioned object, keyed by
CONFIGURATION_OBJECT_KEY(@hyveon/shared,'deployment-config.json') —GetObjectCommand/PutObjectCommand/ListObjectVersionsCommandare reproduced against an in-memory, newest-version-first history, mirroringAwsRemoteFileStore's real command usage. - Seeded with a placeholder
DeploymentConfigon install/reset, so any spec that ends up on this path still gets a validget()without individually stubbing anything. remoteFileStoreMockStore.seed(config)— replaces the object's entire history with a fresh single version containingconfig, for specs (e.g. futureDeploymentConfigService/rollback specs) that need specific configuration content.- Reset per harness —
createIpcHarness()callsremoteFileStoreMockStore.reset()before installing the mock, so no configuration content or version history leaks from one spec'sAppModulecontext into the next. - Driven by
diagnostics-export.spec.ts— that spec setsprocess.env['HYVEON_CONFIG_BUCKET']itself before dispatching (restoring the previous value in itsfinally/teardown), so its calls reachDeploymentConfigService→RemoteFileStore→ this mock for real. Most other specs in the set (e.g.GamesController.listGames/listStatus, dispatched byconfig-service.spec.ts/start-stop.spec.ts) do call intoDeploymentConfigService, but don't setHYVEON_CONFIG_BUCKET, soDeploymentConfigService.getGameServers()catches its ownConfigurationNotConfiguredErrorand returns an empty list before ever reachingRemoteFileStore/S3Client.send()— for those specs this mock is installed and reset but not exercised.
Guided-IAM STS/IAM Mock
guided-iam.spec.ts is a different shape from the other specs in this
inventory: it patches STSClient/IAMClient with aws-sdk-client-mock's
mockClient() inline, in the spec file itself, rather than through a
shared singleton under app/packages/desktop-main/src/test-mocks/ the way
the ECS/DynamoDB/S3 mocks above are wired into createIpcHarness(). It's a
fourth AWS-mock family, reset in a spec-local beforeEach rather than by the
harness.
It also has to work around SafeStorageService.isAvailable() being false
in this plain-Node Playwright process (there is no real Electron runtime to
back it, same as a unit test) — without that, GuidedIamService.rotate()
throws SafeStorageUnavailableError before ever reaching AWS. The spec's
forceKeychainAvailable() helper overrides the DI-resolved
SafeStorageService singleton's isAvailable/encrypt/decrypt to a
pass-through, mirroring the same "keychain available, but plaintext storage"
state GuidedIamService.test.ts's unit tests target, scoped to the fresh
AppModule context ipc compiles per test.
Design Constraints
workers: 1,fullyParallel: false— theMockStoreis an in-process singleton; concurrent tests would corrupt each other's queues.- The mock store resets before and after every test — an automatic (
{ auto: true }) test-scoped fixture,resetMockStore, callsmockStore.reset()in-process in setup and teardown, and Playwright sets automatic fixtures up beforebeforeEachhooks, so responses a hook queues throughserverMocksoripc.mocksare not wiped when the test later requestsipc; there is no HTTP round-trip. - No HTTP server, no Vite build/preview, no
BrowserWindow— every integration spec dispatches directly to theAppModuleDI container via theipcfixture (ipc-harness.ts) and pushes mock ECS responses straight into the in-processMockStoresingleton via theserverMocksfixture (server-mocks.ts), so there is no test-only route surface and nothing for Playwright to boot as awebServer. - No real Pulumi engine, ever —
createIpcHarness()substitutesPulumiServiceStubforPulumiServiceat the DI seam (see PulumiService DI-Seam Stub above), so no integration spec can spawn the Pulumi CLI, download the engine binary, or reach real AWS through it — structurally, not just by convention.
Three Playwright configs
@hyveon/web has three separate Playwright configs — each scoped to a different
testDir so the three suites never cross-pick-up each other's specs:
| Config | testDir | Purpose |
|---|---|---|
playwright.integration.config.ts | e2e/integration-specs | The tier-2 suite documented above. No webServer, no projects — see How to Run. |
playwright.config.ts | e2e/specs | The tier-1 suite (npm run app:test:e2e). Two projects run side by side: electron (ELECTRON_SPECS, ~line 40 — electron-smoke.spec.ts, electron-clean-quit.spec.ts, electron-ipc-roundtrip.spec.ts, ipc-mock.spec.ts, streaming-handle-roundtrip.spec.ts, dashboard.spec.ts, costs.spec.ts, logs.spec.ts, discord.spec.ts, iac.spec.ts, guided-iam-wizard.spec.ts, volume-files-electron.spec.ts) launches the packaged main bundle via _electron.launch(); every other spec runs under chromium against the Vite dev/preview server. npm run app:test:e2e (root package.json) builds @hyveon/shared, @hyveon/cloud-aws, @hyveon/infra, and @hyveon/desktop-preload first, then the workspace's own test:e2e script additionally runs desktop:build (electron-vite) before invoking playwright test — so the electron project always launches an up-to-date out/main/out/preload/out/renderer. |
playwright.screenshots.config.ts | e2e/screenshots | Standalone docs-screenshot harness (docs:screenshots in the root package.json) — deliberately separate from playwright.config.ts so neither suite picks up the other's specs. No projects/webServer; the one spec (e2e/screenshots/capture.spec.ts) manages its own _electron.launch() calls, same pattern as the electron project above. workers: 1/fullyParallel: false because output file paths are fixed (docs/static/img/app/*.png) and concurrent writers would race. |
Tier-1 chromium: in-page bridge fakes
The chromium project runs the web bundle in a plain browser, so installHyveonHttpBridge() (e2e/fixtures/hyveon-http-bridge.ts) installs window.hyveon and forwards most calls to the /api/* routes that stubApis() stubs with page.route. Surfaces that are IPC-only in production have no route to stub, so the bridge carries a self-contained fake for them instead (folderUpload, volumeFiles). Those fakes are canned by default and steered by window flags that a spec sets before the page loads:
| Flag | Fake | Effect |
|---|---|---|
window.__e2eFolderConflicts = true | folderUpload | The run pauses in awaiting-confirmation with a canned overwrite summary until the spec answers. Set from page.addInitScript in games.spec.ts. |
window.__e2eVolumeFiles = { state, volumes, listings } | volumeFiles | Seeds the session state ('stopped' default, or 'running'), the volumes it reports, and the entries list returns per clean absolute directory path (/, /world). Unseeded paths list empty. |
window.__e2eVolumeFilesResponses (read-only) | volumeFiles | Every value any recorded method returned, oldest first (cancelTransfer, onSession and onTransfer are not recorded). |
Seed with the helper rather than writing the addInitScript by hand, and read the record back with its counterpart. Both are exported from e2e/fixtures/index.ts, along with the VolumeFilesSeed type:
import { test, expect, stubApis, seedVolumeFiles, readVolumeFilesResponses, STOPPED_GAME } from '../fixtures/index.js';
import { findSecretFields } from '@hyveon/shared/testing/secretFields';
test('should browse a seeded volume', async ({ dashboard, volumeFiles }) => {
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });
// Call before navigating: it registers an init script, so it only affects pages loaded afterwards.
await seedVolumeFiles(dashboard.page, {
volumes: [{ name: 'data', containerPath: '/data', mounted: true }],
listings: { '/': [{ name: 'world', isDir: true, size: 0, modifiedMs: 1_700_000_000_000 }] },
});
await dashboard.goto();
await dashboard.filesButton('minecraft').click();
await expect(volumeFiles.row('world')).toBeVisible();
// Prove nothing sensitive reached the page; assert the record is non-trivial so this cannot pass vacuously.
const responses = await readVolumeFilesResponses(dashboard.page);
expect(responses.length).toBeGreaterThan(0);
expect(findSecretFields(responses)).toEqual([]);
});
Behaviours of the volumeFiles fake worth knowing before you write a spec:
- The seed is read on every call, not at install. A spec's init script may be registered after the bridge's, so the fake looks up
window.__e2eVolumeFileslazily. sessionStartflips the session torunningfor the rest of the page's life (addingstartedAtand a 30-minuteautoStopAt), whateverstatewas seeded. This is why arriving from the dashboard "Files" action (which auto-starts the session) showsRunningeven with the defaultstoppedseed.- Mutations and transfers fail closed.
mkdir,rename,remove,readText,writeText,download,sessionStop,sessionRestartand the three upload methods resolve{ ok: false, code: 'no-session' }.cancelTransferresolves{ cancelled: false }and is not recorded.onSession/onTransferreturn an unsubscribe no-op, so no push events arrive; cover those in jsdom. - Recording is per-page. The record is an array on
window, re-created each time the bridge installs (every full page load), so read it before reloading. Hash-route navigation, such as dashboard to files, keeps it.
Page objects: VolumeFilesPage (e2e/pages/VolumeFilesPage.ts, registered as the volumeFiles fixture) wraps the /games/:name/files route — goto(game) for chromium, gotoElectron(game) for the packaged app (two-step hash change, see below), sessionState(label), startButton()/stopButton()/restartButton(), row(name), openFolder(name), breadcrumb(), newFolderButton(), uploadFilesButton(), readOnlyBanner(), externalTargets() (a locator for any link or media element in main that targets an http(s):// URL) and renderedStrings() (the text of main plus its href/src/title/aria-label/alt/placeholder/value attribute values). DashboardPage.filesButton(game) is the dashboard card's "Files" action, scoped through the card heading because cards carry no per-game test id.
Specs: volume-files.spec.ts (chromium) covers browsing into a folder from the dashboard action, the read-only banner and disabled toolbar while the server runs, the fixture-response no-secret assertion above (should keep the bridge fixture responses free of secret fields), and a rendered-output audit (should render no credential, IP or URL and no link out to the helper) that runs findSecretFields over renderedStrings() and expects externalTargets() to have a count of zero. volume-files-electron.spec.ts is the Electron counterpart, in ELECTRON_SPECS in playwright.config.ts: it mocks the volumeFiles.session.status/attach/detach/start and volumeFiles.list channels with window.hyveon.__test.mock (see the Electron mock seam), appends each served channel to window.__e2eVolumeCalls and each return value to window.__e2eVolumeResponses, then reads both back with win.evaluate to assert that attach and start were called and that findSecretFields finds nothing; after Start it also runs the same rendered-output checks (renderedStrings() and externalTargets()) against the DOM the app produced. It shares one ElectronApplication across the file and calls clearMocks() in afterEach.
The Playwright specs import @hyveon/shared/testing/secretFields through the package's dist/ export, so @hyveon/shared must be built first — npm run app:test:e2e does that.
Related: the tier-1 Electron e2e IPC mock seam
The seam below belongs to the tier-1 Playwright suite (npm run app:test:e2e),
not the tier-2 suite documented above. It is described here because it is the
other half of the "how do specs fake the backend" story, and the two are easy to
confuse.
The electron Playwright project launches the packaged app via
_electron.launch() with HYVEON_TEST_MODE=1 in the process environment (set in
app/packages/web/playwright.config.ts). That env var gates two things:
-
Main process (
desktop-main/src/electron-entry.ts) logs[desktop-main] HYVEON_TEST_MODE active — test seam enabledat startup, and it is a real behaviour switch, not merely informational:!isTestMode()gates the Pulumi spike (if (isPulumiSpikeEnabled() && !isTestMode()), ~line 239) — without it, aHYVEON_PULUMI_SPIKE=1leaking in from the inherited shell environment (which_electron.launch()spreads into every launch) would make each spec download a 344 MB Pulumi engine and run a realup. The e2e config'selectronEnvalso stripsHYVEON_PULUMI_SPIKE*from the inherited environment as the other half of that belt. Aside from this gate the window still opens normally, so_electron.launch()can drive the real UI. -
Preload script (
desktop-preload/src/preload.ts) checksprocess.env.HYVEON_TEST_MODE === '1'before attaching the__testnamespace to thehyveonbridge. When the flag is set, the bridge gains:window.hyveon.__test.mock(channel, handler)channelis an IPC channel string (e.g.'games.list').handleris a replacement function or a plain value. Thereafter everyinvoke(channel, ...args)call in the preload consults aMap<string, fn>before forwarding toipcRenderer.invoke, so the Electron main process is never reached for mocked channels.
Production-gating guarantee
When HYVEON_TEST_MODE is absent (the default for packaged/production builds),
the if (isTestMode) branch in the preload is never entered and
window.hyveon.__test is undefined. The contextBridge.exposeInMainWorld call
only ever exposes the production API namespaces. There is no path by which end
users can reach the mock registry.
Two mock surfaces — choose the right one
| Surface | File | When to use |
|---|---|---|
window.hyveon.__test.mock(channel, handler) | desktop-preload/src/preload.ts | Playwright Electron e2e specs (electron project) that need to control IPC responses without running the Nest server. Called via win.evaluate(...) inside each test body (or a beforeEach when all tests in a describe share the same mock). When tests share a single ElectronApplication, call win.evaluate(() => window.hyveon.__test.clearMocks()) (alias: reset()) in afterEach so stale mock handlers don't bleed into later tests. |
register(namespace, mock) from @hyveon/desktop-preload/test-mock-registry | desktop-preload/src/test-mock-registry.ts | Vitest unit tests running under jsdom. Build a partial namespace stub with vi.fn(), call register('games', stub), then vi.stubGlobal('hyveon', buildMockHyveon()) so the component under test gets a fully-typed window.hyveon. Call clear() in afterEach. |
The test-mock-registry module is not imported by the preload script or any
production code; it exists only for jsdom-environment test helpers.
Alternative pattern: fresh ElectronApplication per test
The shared-app + clearMocks() pattern above assumes there's a remount lever
— routed-page specs like discord.spec.ts get per-test isolation from
DiscordPage.goto()'s two-step window.location.hash assignment (set to '/'
to unmount any previously mounted Discord page, then to '/discord' to force a
fresh mount) forcing a hashchange — a same-value location.hash set fires no
hashchange, so HashRouter would not re-render on a single assignment — not
from clearing mocks alone. A component that mounts once and never remounts
has no such lever: guided-iam-wizard.spec.ts covers the first-run wizard
shell, which mounts once outside the router on app boot, so clearMocks()
between tests would leave a prior test's settled state in place with nothing
to re-drive it.
That spec instead launches a brand-new ElectronApplication in beforeEach
and closes it in afterEach (the same per-test-launch pattern logs.spec.ts
uses), seeding each fresh app's mocks immediately after firstWindow()
resolves and before the renderer's mount effect can fire. Reach for this
pattern for any spec targeting a component that mounts once with no
navigation-driven remount — it's slower than sharing one app, but it's the
only way to guarantee each test starts from a truly fresh mount.
Known limitation. A mock handler registered through contextBridge cannot be
backed by a real async generator — Electron's structured clone across the bridge
drops the generator protocol. Assertions on streamed chunk content belong in
jsdom/Vitest specs instead.
Related: unit-tier React component and routed-page specs
Also tier-adjacent rather than tier-2: the conventions for the Vitest specs that
run under jsdom in @hyveon/web. They live here so there is one page describing
how each tier fakes its dependencies.
Stack: Vitest + jsdom + @testing-library/react + @testing-library/user-event.
The @testing-library/jest-dom matchers (toBeInTheDocument, toHaveTextContent)
are registered globally by app/vitest.setup.ts, which also wires
afterEach(cleanup) — that is not automatic here because the suite runs with
globals: false, which disables React Testing Library's own cleanup hook.
The node/jsdom split lives in app/vitest.config.ts as two projects, node
and web (Vitest 4 removed environmentMatchGlobs). Both inherit the root
config via extends: true — resolve aliases, the maxWorkers cap, setupFiles,
mock resets — and differ only in which files they collect and the environment
those files run under: web collects packages/web/**/*.test.{ts,tsx} under
jsdom, node collects everything else under node.
Hook specs
-
A stateful hook shared by more than one page or component (
src/hooks/*.hook.ts) gets its own co-located*.hook.test.ts, testing the hook in isolation via@testing-library/react'srenderHook, rather than being exercised only indirectly through each consumer's page/component spec.useLogTail(shared byLogsPageandInfrastructureLogsPage) is tested this way atuse-log-tail.hook.test.ts; each page's own*.test.tsxthen only needs to cover how that page wires the hook's result into its markup, not the tail/pause/level-filter/autoscroll state machine itself. -
Hooks that belong to one feature live next to that feature rather than in
src/hooks/:src/components/volume-files/holdsuse-volume-session.hook.test.tsx,use-volume-files.hook.test.tsxanduse-volume-transfers.hook.test.tsx, each mocking../../api.service.jswith avi.hoistedapiMockand capturing thevolumeOnSession/volumeOnTransfercallback so a test can push an event insideact(...).use-volume-sessionneeds aPollingProviderwrapper. The shared buildersmakeSessionStatus,makeDirEntry,makeFileEntryandmakeTransfercome fromvolume-files.test-utils.tsin the same folder — reuse them (the three hook tests,volume-files.utils.test.tsand the routed-page specpages/volume-files.page.test.tsxall do) instead of hand-rolling status objects.
Component specs
- Live next to the component (
foo.component.tsx→foo.component.test.tsx), not in a separate__tests__directory. - Mock the API client and any module-level singleton with
vi.mock. - For a component driven by a streaming channel (
logs.stream,iac.stack.initialize,iac.runs.logs— bridged overiac.runs.logs.chunk/iac.runs.logs.end), back the mock withtoStreamHandleMock()fromsrc/test-utils/stream-handle.test-utils.ts. It wraps an ordinary async generator body in theHyveonStreamHandleshape the real preload bridge returns — including thecancel()method components call on unmount, which a bareAsyncGeneratordoes not have. - Cover: visible rendering for each
statebranch, every callback prop firing with the right argument, internal state transitions (open/close, pause/resume), and any non-trivial pure helper. - Avoid snapshots — they break on every Tailwind tweak — and don't duplicate assertions the e2e tier already makes about routing and real streaming.
Routed-page specs
Each routed page (DashboardPage, CostsPage, DiscordPage, LogsPage,
InfrastructureLogsPage, SettingsPage, VolumeFilesPage, …) has a co-located *.test.tsx that mounts it through
renderPage() from app/packages/web/src/test-utils/render-page.utils.tsx. That
helper wraps children in the production provider stack —
PollingProvider → GameStatusProvider → MemoryRouter — so the page is exercised
under the same context it gets at runtime. Pass initialEntries when the page
reads useLocation (a { pathname, state } entry when it also reads
location.state, as the rollback flow does).
Mock ../api.service.js with vi.mock + vi.hoisted so the page runs off canned data,
and stub every method the provider stack calls, not just the ones the page
calls — at minimum api.status and api.costsEstimate. GameStatusProvider
invokes api.costsEstimate() unconditionally on mount
(src/polling/game-status-provider.component.tsx, in a bare useEffect), above
every page mounted this way. That call's rejection is swallowed
(.catch(() => undefined)), so leaving api.costsEstimate unstubbed does not
hang the test — it silently leaves estimates at its initial null instead,
which can mask a real assertion failure with a confusing symptom rather than a
useful message, so stub it anyway.
A page that reads a route parameter needs the matching <Routes> around it. volume-files.page.test.tsx renders <Routes><Route path="/games/:name/files" element={<VolumeFilesPage />} /></Routes> through renderPage(), passing initialEntries as [{ pathname: '/games/mc/files', state: { autoStart: true } }] to cover arriving from the dashboard "Files" action (and a bare path string for opening the route directly). It stubs the page's whole volume* surface of api plus folder* and the provider-stack methods, captures the volumeOnTransfer and folderOnProgress callbacks to push progress events, and enables vi.useFakeTimers({ shouldAdvanceTime: true }) only inside the describe block that needs timers.
Keep the scope tight: smoke-render each header section, exercise controls not already covered by a child component's own spec, and verify the polling-indicator wiring. Anything needing the real DI container belongs in the tier-2 specs above.