Testing
Baander uses PHPUnit 13 with four test suites in the tracked phpunit.xml.dist.
Disposable container runners exercise the application's PHP runtime without using
the development database or Redis instance. The Makefile test targets use the same
disposable coordinator.
Web audio graph
Run yarn --cwd ui/web test:audio-graph after installing Chromium, Firefox and
WebKit with yarn --cwd ui/web exec playwright install chromium firefox webkit.
WebKit needs the host libraries that Playwright's Ubuntu images provide; on other
hosts, run the suite in the mcr.microsoft.com/playwright image of the installed
Playwright version, with --network none. The runner also requires
OpenSSL for its temporary HTTPS certificate. It serves a disposable fixture at
audio.baander.app, explicitly mapped to loopback; it does not contact the public
application.
These browser tests render the real AudioProcessor nodes with
OfflineAudioContext and compare stereo, processing order, and normalization
against reference signals. They replace external WASM analysis modules;
they do not certify production codecs or audible clicks during live graph
changes. A separate loader check serves the production TypeScript loader and
shipped WASM to Chromium, checking instance memory and analysis-state isolation.
Stereo metering cases additionally run the production metering worklet and WASM
against native mono, right-only, asymmetric, and opposite-phase signals, and
compare the independent-channel analyser fallback. The same native cases verify
paired phase samples and correlation; worklet tests also cover quadrature,
silence, ring ordering, and programme reset. Native media tests use locally generated WAV files and the
real playback hook, store, service, and processor to exercise repeated preloaded
handoffs, crossfade overlap, promoted-element controls, and interrupted fades.
One resume regression delays a context-resume rejection across track reselection
and verifies that the newer native playback continues advancing. Hook unit tests
also cover deferred success, native play rejection, and same-track replay.
The active-media error regression reloads an already playing element to a local
404 response, checks the real Chromium MediaError, and verifies stop and
reselection recovery.
Two specs cover a stream that the server cuts midway, as it does when the web
server drains.
media-interruption.spec.ts plays a plain <audio> element in Chromium, Firefox
and WebKit through a loopback proxy that resets the connection mid-body, for a
chunked response and for a Content-Length response. It asserts that each engine
reports an error or a stall, and that any ended event comes after an error:
Firefox reports the error, plays what it buffered and then fires ended. A clean
chunked end, which fires ended in every engine, is recorded for contrast. A
second test records how each engine reloads the same URL after an outage and
seeks to a saved position. stream-recovery.spec.ts runs the real player hook
and store in Chromium through an outage in which /live and the stream answer
502. It checks that the player shows it is reconnecting, holds its clock at the
saved position, resumes the same track there once /live answers, and records
one play. Playwright's Linux WebKit decodes media with GStreamer, not Safari's
media stack, so Safari's resume is a manual check.
These verify element ownership and timing, not sample-accurate gaplessness or
authenticated streaming. Rebuild timing and worklet graph attachment
also have unit regressions in audio-processor-rebuild.test.ts. Deferred module
and WASM loading, connection replacement, and React StrictMode cleanup have
processor, service, and playback-hook regressions. Native browser lifecycle tests
use small fixture worklets to verify readiness and teardown; they do not replace
tests of the production WASM algorithms. The browser suite runs as a blocking
step in the frontend workflow.
Run bash scripts/test-dsp-analysis.sh with Emscripten 6.0.3 to test the actual
WASM analysis modules and production worklet code against reference signals.
The gate rebuilds twice and checks reproducibility and shipped artifact parity.
See DSP qualification for covered contracts and
the remaining loudness and codec qualification limits.
Live-update qualification
The live-update guide describes adding producers and readers. Start with the frontend contracts and the disposable backend selection:
yarn --cwd ui/web test tests/live-updates
bash scripts/test-backend-container.sh tests/Unit/Shared/LiveUpdates
The frontend suite covers protocol parsing, the AST consumer census, generation tracking, bounded HTTP admission, recovery, account ownership, feature readers, drafts, preference mirrors, and shared sampling. The census requires classification of actual consuming calls and named behavior proofs; a classified call or a proof file's existence alone does not establish a passing browser flow. PostgreSQL capture tests must also exercise commit, rollback, deletion dependencies, audience changes, and bulk writes through the disposable backend runner. Do not use a DAMA-wrapped request to infer visibility from an independent producer connection.
For the real-browser capacity scenario, refresh this worktree's isolated preview and pass its URL explicitly:
make preview-up
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless \
tests/e2e/live-updates/capacity.spec.ts --retries=0
Run this workload serially with other preview mutations and failure drills. It
uses 32 distinct signed-in principals and a separate administrator diagnostics
client through the real HTTP, WebSocket, authentication, and rendering paths.
The managed fixture executor creates test-owned accounts and media, records
ownership in var/live-capacity/<run>/manifest.json, and cleans only its own
objects. Preserve an interrupted manifest until cleanup succeeds. The long-lived
executor holds the preview lifecycle lock; its burst receipts and HTTP diagnostics
provide concurrent observations without a second CLI invocation behind that lock.
Keep credentials out of saved receipts.
The preview capacity test has a 900-second deadline and retains
capacity-raw-receipts.json in its Playwright output. Keep failed attempts too.
Record the browser version, native PHP/Swoole runtime, worker identity, pool limits,
and preview development/debug/Vite overhead with timings. Sharing the pinned
native base with CI does not turn a preview result into production-mode performance
evidence.
Acceptance targets are p95 committed change to rendered eligible view within
three seconds for 32 principals when source reads finish within one second, and
final convergence within five seconds after 10,000 capture rows over ten seconds.
These are qualification targets, not measured results. Distinguish producer commit
brackets from host dispatch-to-render upper bounds. Notification observations need
the final visible title as well as the accessible unread count: a painted 99+
badge cannot establish final convergence. Report durable backlog drain separately.
Use Chromium's Request.timing().responseEnd for source HTTP duration and retain
host callback times separately. Awaiting a Playwright protocol response before
recording completion can add observer delay after the browser has already painted
the data. Require valid browser timings; do not substitute host durations when
those timings are absent. Keep ordinary and burst measurements separate.
A historical 2026-10-30 preview run passed with 320 ordinary observations: p95 1,765ms, worst-principal p95 2,146ms, and final burst convergence within 1,455.71ms. The producer committed 10,000 capture rows in 10,029.28ms. All 1,344 source reads finished within 956.83ms; clients peaked at two concurrent GETs and four starts per second. Pool assignments returned to their quiet envelopes and owned cleanup left no survivors or pending capture rows. These results describe that native preview workload. Later application fixes do not make that historical run a qualification of the current production configuration. The roadmap records the retained receipts and current acceptance status.
For production-mode capacity, select the CI image built from the checkout under qualification and resolve its immutable ID before starting the disposable runner:
CI_IMAGE_ID="$(docker image inspect --format '{{.Id}}' baander-ci:local)"
BAANDER_TEST_IMAGE="$CI_IMAGE_ID" BAANDER_TEST_CHECKOUT_IN_IMAGE=1 \
php scripts/test-live-capacity-container.php
The runner executes capacity and both populated-view cases in one serial browser
invocation. Its overall budget is 4,200 seconds: 1,800 seconds for capacity,
two 900-second populated-view cases, and 600 seconds for setup and
cleanup. Login uses the production limiter; its admission and 429 retry delays
belong to setup, not the measured latency windows. These execution budgets leave
the measured acceptance thresholds unchanged. The runner verifies the selected
image, backend source, fixture and built frontend identities. It requires
APP_ENV=prod and APP_DEBUG=0 and rejects a Vite frontend. It uses disposable
services and the real OAuth/DPoP, WebSocket, projection and compiled service-pool
paths. Keep the runtime identity, raw browser receipts and cleanup ledger with the
result. A passing preview or shared native base does not replace this gate.
The first CI-image run passed both populated-view cases, but its capacity case
stopped during the 26th login, before measurement, and completed owned cleanup
without survivors. The subsequent login-navigation fix has focused regression
coverage. The exact cause of the earlier blank-root observation was not isolated.
A login setup failure supplies no ordinary or burst latency result.
The next CI attempt completed all 32 logins but failed the readiness barrier. Its recorded reconnect intervals were 301.503–303.561 seconds: outgoing health messages did not refresh Swoole's client-receive idle timer. The shared transport now sends an owned 30-second keepalive, with focused lifecycle regressions. That attempt also produced no capacity measurements. A later rebuilt CI image passed the capacity case; the earlier failures remain retained history.
The latest 2026-10-31 capacity case passed in 957.117 seconds on immutable image
sha256:a591d2f471859c98c0eae1c474d6ffc30b2825d596f332d5f371dda3dab48526,
built from checkout db2c5cd2d779a7878795d0f45efce18e9cae11a4. Its identity
receipt records PHP 8.5.2, Swoole 6.2.0, production mode, debugging disabled and
the built frontend. Production-limited setup took 918.557 seconds before
measurement. Each of the 32 clients recorded one readiness cycle; the oldest had
been ready for 906.980 seconds when measurement began.
| Measurement | Passing CI result |
|---|---|
| Ordinary visible renders | 320 observations; p95 892ms; worst-principal p95 and overall maximum 950ms. |
| Burst production | Exactly 10,000 capture rows in 10,029.440ms, or 997.065 rows/second. |
| Final count and visible marker | All 32 clients converged within a 1,187.106ms host-anchored upper bound from the final commit's start bracket. |
| Observed burst inbox drain | Empty within a 2,223.106ms upper bound from the same host-anchored final-commit-start bracket. |
| Source HTTP | All 1,344 reads returned 200 with valid Chromium timings; maximum 196.982ms overall and 170.419ms during burst/recovery. Every phase met the one-second source premise. |
| HTTP admission | At most two concurrent measured GETs and four starts per second per client. |
| Release and cleanup | Subscriptions returned from 32 to zero; all four workers met their warmed pool-assignment envelopes; owned users, tokens and survivors were zero after cleanup. |
The final-render bound anchors producer-relative commit-start intervals at host dispatch, avoiding an assumed producer/host clock offset. Inbox drain uses the same conservative anchor. Each worker retained two new lazily created pools, both with zero active assignments; unchanged object counts are not the release criterion. The 32 complete SPAs, PHP diagnostics client and receipt bindings add observation cost, and periodic samples can miss short peaks. Retain these limits and the raw receipts when comparing another configuration. Allocator usage and object counts did not return to identical cold baselines; sampled reclamation and released pool assignments do not establish indefinite memory stability.
That three-case invocation recorded two passes and one failure: capacity and retained song pages passed, while Timeline failed its host-event concurrency assertion. A subsequent observer attempt also failed because it combined Playwright timing fields with different origins. Both failed runs remain in the verification record. After correcting the observer to use one browser clock, both populated-view cases passed in a targeted 6.9-minute invocation on the same immutable image. Application sources, built assets and fixtures were unchanged; the earlier capacity pass still qualifies that runtime. Owned cleanup completed with no survivors or retained manifests and empty scoped and global inboxes.
The HTTPS ingress is a separate owned container using the same selected immutable CI image. Only ingress joins both the browser-facing network and the internal network; the application, PostgreSQL and Redis remain internal. The application trusts the ingress container's exact internal IP as its proxy. The runner still requires the published browser endpoint to bind to host loopback.
The backend source digest compares application and package sources, migrations,
configuration and composer.lock. It excludes generated config/reference.php,
which Symfony rewrites during cache compilation. Fixture and built-frontend
hashes are checked separately. capacity-runtime-identity.json records the actual
PHP and Swoole versions, environment, debug setting, frontend and immutable image
identity. A successful identity check establishes the tested setup; workload
latency, populated-view behavior and cleanup still require their own passing
receipts.
A populated infinite view with ten retained pages and a 10,000-item catalog needs its own read and full-walk timings, with loaded content, selection, and scroll preserved. Recovery evidence must distinguish a socket outage, an open socket with degraded publication, an authorization reset or denial, and failed resource HTTP reads. Keep cross-client and background mutations, stale in-flight responses, unsaved drafts, preference conflicts, and playback continuity in that verification. Compare warmed per-worker pool observations before and after the workload through the same observer path; the diagnostics request can itself borrow services. Missing workers, persistent extra assignments, and progressive growth need an explanation rather than a cold-versus-warm allocation comparison.
Count retained pages separately from HTTP attempts. A startup or recovery read can repeat the first cursor without adding a retained page. Keep every attempt, verify the ten distinct cursors and the rendered 1,000-row virtual spacer, then measure the exact ten-page refresh from the committed change. Timeline has a separate 100-page walk over 10,000 albums; a hint during that walk must produce one later serial walk. Report its complete-walk latency and request amplification separately from notification latency.
Concurrency requires start and end timestamps from the same clock origin. The
pinned Playwright Chromium adapter shifts Request.timing().startTime to the
response's request time, but measures responseEnd from the original request
event. Adding them counts an interception delay twice. The observer retains those
fields and host callback order as diagnostics; its concurrency gate uses raw CDP
Network.requestWillBeSent.timestamp and Network.loadingFinished.timestamp.
Failed requests retain their completion event. Missing timing receipts, unexpected
redirects or a count mismatch fail qualification; no overlap tolerance is applied.
The corrected observer's retained-song case loaded ten logical pages of 100 songs through twelve initial HTTP attempts, including two repeated startup reads. The committed-change refresh made exactly ten successful reads over the same ten cursors. Dispatch to the refreshed visible frame took 1,931ms; the selected public ID and scroll position of 16,000px were preserved. All 22 requests had matching host and CDP receipts. CDP concurrency peaked at one; host callbacks reported two.
Timeline rendered 10,000 albums through two serial 100-page walks after a hint
during page 25. All 200 requests returned 200 with 100 albums in exact page order,
and every attempt had a matching CDP interval. Peak concurrency was one, with no
missing timings or observer errors. The first walk took 17,341ms and the second
15,074ms; start to the final visible-title frame took 38,262ms. Request
amplification was 2×. Host callbacks again reported a peak of two. Both cases
completed cleanup with zero survivors, empty scoped and global inboxes, and no
retained manifests. Keep their qualification.json receipts with the separate
capacity result.
An earlier 2026-10-31 CI invocation passed both populated-view cases with PHP 8.5.2, Swoole 6.2.0, production mode, debugging disabled and the built frontend. These historical measurements predate the final review fixes and do not qualify the latest Timeline run:
| Workload | Data and requests | Host-observed timing |
|---|---|---|
| Retained song pages | Ten pages of 100 songs; ten initial reads and exactly ten refresh reads; selected public ID and scroll position preserved. | Change dispatch to refreshed visible frame: 2,930ms; observed song-request concurrency peaked at two. |
| Timeline | 10,000 rendered albums; a hint during page 25 required two serial 100-page walks, with 200 successful reads of 100 albums and peak concurrency one. | First walk: 17,136ms; final walk: 15,012ms; start to final visible-title frame: 38,046ms; request amplification: 2×. |
Both fixtures completed cleanup with zero owned survivors, zero scoped capture backlog and no retained manifest after bounded drain retries. These host timings include observer and render work; they are separate from source HTTP durations and notification latency. Keep the runtime identity and both qualification JSON files with these results; the roadmap records their artifact locations. The Timeline fixture has no album covers. Its 200 metadata reads do not measure cover-image refresh fanout or establish latency for an image-bearing Timeline.
Run the recovery, populated-view and scanner scenarios against the same isolated preview, serially with capacity and other lifecycle commands:
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless \
tests/e2e/live-updates/recovery.spec.ts \
tests/e2e/live-updates/populated-views.spec.ts \
tests/e2e/live-updates/scan.spec.ts --retries=0
Before preparing scan media, the browser scenario records a fresh GET /health
response and requires its messenger component to be healthy. A healthy top-level
status or saved worker-readiness flag does not establish that background workers
are running.
The scanner scenario then prepares a tagged WAV and local lyrics, runs the real
app:library:scan command, and observes worker ingestion through the mounted
album and its authorized API response. It requires automatic metadata-provider
sync to be disabled and no matching global webhooks. It checks those conditions
without changing them. Owned notification preferences disable external delivery;
the local lyrics prevent a provider lookup. Cleanup waits for the owned jobs,
outbox events and queued deliveries to finish before deleting their data. An
unfinished or unobservable chain retains its manifest for inspection and cleanup.
Do not clear shared queues to make a failed scenario pass.
Managed preview lifecycle cases close the fixture executor before requesting a worker reload or full restart, then reopen the same owned run for observation and cleanup. The lifecycle helper owns its direct PHP child through a 3,600-second command deadline and a separate 120-second shutdown budget. Teardown requests TERM and waits for confirmed child closure before reopening the fixture. An uncertain close retains the command receipt and manifest; it does not grant permission for a replacement command or concurrent cleanup. Fixture cleanup has its own bounded 90-second retry period and must finish without survivors.
Recorded preview passes cover the recovery scenarios, ten retained song pages and the serial 10K Timeline walk, including owned cleanup. The scanner's worker-health precondition rejected an exited worker pair. Managed preview recovery restored worker availability without resetting data; the retained scan events then relayed, the owned track was ingested and cleanup completed without survivors. A fresh scanner browser case also passed with healthy workers. This qualifies the scan and ingest path under normal worker availability; the original worker-exit exception remains unidentified. The earlier CI-image populated-view passes remain historical evidence. The latest image passed capacity and retained song pages; Timeline remains open as recorded above.
Keep the following evidence boundaries when reporting acceptance:
| Behavior | Evidence boundary |
|---|---|
| Cross-client playlists, private notifications and read agreement | Real authorized browser contexts, a denied identity, and same-account mark-read convergence. The playlist producer is an independent committed fixture writer. |
| Interrupted delivery, wake, worker reload and full restart | Real browser transport, frozen-page lifecycle, managed preview controls and subsequent rendered changes. Lifecycle cases do not themselves commit while the server is down. |
| Dirty metadata and playback continuity | Browser holds the user's own successful PATCH, enters newer text, refreshes an unrelated notification and reconnects. It verifies the same audio element, source and queue with advancing native time. |
| Remote metadata conflict, deletion and access loss | Component/coordinator and metadata-hook tests establish conflict choices, unavailable state and draft retention. The browser audio/draft case does not perform a remote metadata edit or deletion. |
| Preference conflicts and feedback suppression | Coordinator/hook/component tests establish retained edits, version conflicts, bounded retry and no feedback writes. There is no preference-conflict browser case. |
These are composed proofs at their respective boundaries. Neither inventory classification nor the historical capacity result substitutes for an unrun gate. Record delivered scope, retained artifacts and actual results in the roadmap.
Spectrum and Equalizer calibration
The native native-spectrum-calibration.spec.ts under the audio-graph suite
compares the analyser with the shipped FFT/worklet at 44.1 and 48 kHz using
known offline tones and silence. The authenticated UI check is separate:
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless \
tests/e2e/styling/visualizer-calibration.spec.ts
Start or refresh the isolated preview first. The UI fixture renders all seven modes across four moods using a local stereo WAV with browser output muted and service workers blocked for fixture interception. Preference writes receive local acknowledgments; authentication and preference reads remain real. Keep system output muted too. The display measures input before EQ and gain; it does not establish preference synchronization, acoustic, post-EQ or production service-worker qualification. See the audio-graph guide and FFT contract for the numeric scale and the two test boundaries.
Browser authentication transport
yarn --cwd ui/web test:browser-auth runs ui/web/tests/e2e/browser-auth/
against a scripted server on the loopback; it runs as a blocking step in the
frontend workflow. The Chromium project runs every scenario except the ones
tagged @webkit, with the named hosts web.baander.app, api.baander.app and
foreign.baander.app resolved to the loopback through a browser flag. The WebKit
project runs only the tagged scenarios, which check what WebKit's storage gives
back; WebKit has no such flag, so that project uses localhost and 127.0.0.1
as two secure origins and skips the foreign host. These loopback names are the
one accepted exception to the baander.app rule in AGENTS.md. On a host
without a WebKit build, run the suite in the Playwright image with the
repository mounted and node node_modules/@playwright/test/cli.js test -c tests/e2e/browser-auth/playwright.config.ts from ui/web.
The scripted server's refresh state machine (refresh-state.ts) answers refresh
tokens the way /api/auth/refresh does on PostgreSQL and is pinned to
tests/Integration/RefreshTokenGraceTest.php; it also answers logout, and an
outage mode makes refresh and logout answer 503 with Retry-After until the
test ends it. The suite checks that the service worker signs native image and
media requests with the page's DPoP credentials and sends none to a foreign
origin, that an expired token refreshes once and retries, and that a refresh
failure is bounded. Its modes cover a nonce challenge answered with one
re-signed proof, a 503 with Retry-After that keeps the session and retries
after the delay, a lost refresh response re-presented inside the grace window,
two tabs sharing one real credential store so the second tab adopts the pair the
first rotated instead of presenting a consumed token, and a service worker
stopped through CDP that pulls its credentials back from the page before signing
the next request.
The pending-logout scenarios run on the real auth store. A logout while offline
ends the session at once and is sent exactly once when the browser comes back
online; a logout during a server outage is kept and delivered on the next
trigger once the outage ends; a reopened page whose entry's access token expired
refreshes first and logs out with the new token; an entry whose chain was
revoked elsewhere is dropped after the logout 401 and the refresh refusal; two
pages of one origin send one logout for one entry; and the stored key of a
pending logout reads back after a reload and still signs a proof. Mock proofs
qualify the browser transport; they do not validate the server's DPoP
cryptography.
Web store isolation and debugging
Run yarn --cwd ui/web test:store-debug for the disposable Chromium workflow at
https://debug.baander.app:5187, mapped to loopback with a temporary certificate.
It checks production selection/view-mode stores, render isolation, no-op updates,
and enabling, inspecting, exporting, and disabling the store debugger. The frontend
workflow runs this gate. Local prerequisites are Chromium and OpenSSL, as for the
audio graph tests; port 5187 must be free.
In a development build, enable the debug panel from Diagnostics, open Store Timeline
or Store Inspector, and select Enable store tracing and reload. Recording is
opt-in through baander-store-debug in local storage and is disabled in production
builds. Disabling also reloads, removing tracing wrappers instead of keeping a
conditional branch on every action. Enabling recording incurs snapshot/stack costs.
The timeline exports schema-version-2 JSON with ordered events, recorder instance
IDs, current captures and a history baseline. Each admitted store records a
registration capture after successful creation; construction-time intermediate
states are outside the recorded history. Duplicate display names have distinct instance IDs. Explicit
unregisterStore(instanceId) retirement releases observation and removes that
instance from later historical points and the current Inspector. Retirement does
not happen automatically when a component unmounts.
Select a retained event to inspect Historical store captures for every recorded instance present at that point. Reconstruction folds the full retained history through the selected event, even when the timeline filter hides intervening events. It uses the baseline and recorded captures, without reading newer live state, changing stores or running effects. Update before/after captures still belong to the selected event. Clearing or evicting that event removes selection. Collections over 50 instances use a keyboard-accessible virtualized scroll region; rows keep their expanded or collapsed state while scrolling.
Evicted events advance the baseline to the start of the retained window. Clearing the timeline starts a new baseline epoch from current recorded captures; instance and event IDs are not reused. At most 100 active instances are admitted. Omitted registrations remain uninstrumented and increase the visible cumulative omission count, which persists across clearing and retirement. A nonzero count marks reconstruction as partial even when each displayed capture is complete. Retiring an admitted instance frees a slot for a later registration.
Synchronous nested calls retain parent links. Async completions link to their
originating action; updates after an await retain their call stacks without
inferred parentage. Redacted, truncated and native values make captures incomplete.
Even complete captures are sanitized diagnostic values, not executable application
state. Reconstruction does not restore a running application or replay network or
media effects. Schema v1 has no compatibility conversion, and trace import is not
supported.
The timeline can verify one retained catalog.selection select or clear
action, including a successful no-op. It calculates the expected result with the
current pure transition shared by the live store, using detached captured data
and a trusted descriptor held by the recorder. Verification requires complete
starting-state, argument, update and terminal captures, matching transition
identity/version, and an unambiguous successful synchronous invocation. Nested,
foreign or unattributed updates and broken invocation boundaries make verification
unavailable. Results are match, mismatch or unavailable with a reason; a no-op
remains an explicit recorded action even when it emits no update.
Verification leaves live stores and recorder history unchanged and runs no live effects. It supports neither async replay nor trace import. Schema-version-2 records without the optional invocation provenance remain inspectable but are unavailable for verification. See the transition verifier for the admission checks.
Unit tests verify creator/action identity when tracing is disabled, no-op subscriber and persistence behavior, secret redaction, immutable snapshots, and event ordering. They also cover instance registration and retirement, nested subscriber mutations, baseline eviction and clear, admission omissions, and reconstruction without effects. Player/session tests additionally cover clock isolation and sync under continuous playback. Performance assertions use counts rather than timing-sensitive thresholds.
Test Suites
| Suite | Directory | Scope |
|---|---|---|
| Unit | tests/Unit/ |
Domain behavior and application/infrastructure contracts; no Symfony kernel boot |
| StaticAnalysisRules | packages/baander-phpstan-rules/tests/ |
Custom PHPStan rules, including parsed-source request-payload fixtures |
| Functional | tests/Functional/ |
With Symfony kernel container, database, and services |
| Integration | tests/Integration/ |
Database, transport, and other integration contracts against configured test services |
Four suites cover the session work of ROADMAP item 5 and run through the
functional runner in the CI image (BAANDER_TEST_IMAGE=baander-ci-prewarm:local bash scripts/test-functional-container.sh <file>); the integration ones need the
runner's disposable PostgreSQL and Redis and do not run on the host:
tests/Integration/RefreshTokenGraceTest.php: the lost-response grace ofauth.refresh.reuse_grace_secondsand every other reuse of a refresh token through the production refresh endpoint. A consumed token presented again with its own DPoP key inside the window, while its replacement is unused, gets a fresh pair; every other reuse revokes the chain before the401, and a different key revokes nothing. The two-session cases run a child process (tests/Fixtures/Auth/refresh-race.php) that waits on a row lock.tests/Integration/RefreshTokenConcurrencyTest.php: two database sessions presenting one token leave exactly one usable pair; a logout concurrent with a refresh ends the chain without a deadlock; a signing or flush failure after the consume rolls back and the retry succeeds; a login whose signing fails commits nothing; a commit whose acknowledgement is lost leaves a usable pair; a lock held past the lock timeout answers503inside the web client's attempt timeout; fifty refreshes leave the connection pools at their starting sizes. The faults are inert injectors in the production kernel (tests/Fixtures/Auth/IssuanceFaults.php).tests/Integration/DpopStoreOutageTest.php: the five DPoP edges during a Redis outage. A refused store (a kernel whose Redis clients point at a closed port) and a hung store (CLIENT PAUSEon the disposable Redis) both get503withRetry-After, noDPoP-Nonceand noWWW-Authenticate, inside the web client's attempt timeout; a genuine replay is still403; a refresh whose post-commit nonce store fails answers200without a nonce; and the same token refreshes once the store answers. Logout and/api/oauth/revokeare the exception: during the refused outage and under pool exhaustion they answer200and revoke the chain. Every other proof check still refuses a wrong key, hash, method, URL or age; a replay of a successful logout meets the revoked token; and the nonce store is never touched.tests/Integration/RefreshTokenGraceTest.phpalso proves the socket reconciliation: with the connections of a revoked chain, an expired chain, a live chain and a chain whose handshake token a rotation revoked registered, one pass ofRevokedSocketReconcilercloses the first two and leaves the other two open, and a logout whose close the web server could not finish answers200with the error logged. The unit probetests/Unit/Auth/Infrastructure/Swoole/RevokedSocketReconcilerProbeTest.phpproves each tick releases its pooled services.tests/Functional/Auth/LogoutTest.php: logout revokes the chain of the access token that authenticated the request, so neither token works afterwards, and/api/oauth/revokerevokes only the caller's own tokens and answers a foreign token exactly like an unknown one.
The video delivery surface has its own real-firewall matrix in
tests/Integration/SignedDeliveryFirewallTest.php, run through the same runner
against the disposable PostgreSQL and Redis. A player holds a signed manifest URL
and may or may not hold a token, so the suite crosses anonymous, owner, library
member, unrelated user and administrator with every credential condition on the
seven signature-only delivery endpoints (the HLS master, media and subtitle
manifests, the DASH manifest, the init, media and subtitle segments), the signing
endpoint and the quality ladder. The contract it records follows the order the
firewall works in. A valid signed URL is a bearer capability and serves any holder
without a credential. A credential that is presented must be valid: an expired,
revoked, foreign-audience, malformed or fingerprint-mismatched token answers 401
even with a valid signature. The DPoP proof is never read on a delivery route, so a
missing, foreign-key or reused proof answers 200 there and records no replay key.
A signature fault answers 403 on every delivery route before the streaming port
is called, the segment index included. On the signing endpoint and the ladder, a
missing proof, a foreign key, a reused proof and an unbound token answer 403, a
bad token 401, and a video of another library 403. A DPoP-bound token
presented with the Bearer scheme answers 401 everywhere the api firewall
authenticates. Every refusal row also asserts which component refused: the API
answers every 403 with one generic body, so the suite registers a
kernel-exception probe that records the refusing exception's message, and the
401 rows read the authenticator's code from the body. One assertion scans every
response body and every log file of the kernel for the tokens, proofs and
signatures the matrix used; the signature scan is what found the request URL,
signature included, in the web processor's url field and the access log, and
StreamSignatureRedactionProcessor now redacts it.
tests/Unit/Shared/Infrastructure/Security/DeliveryRouteInventoryTest.php keeps
the delivery route set equal across the security configuration, the binding
listener's exemption list and the rate limiter's exemption list, and the suite
derives its unsigned-request loop from that inventory.
The catalog, library and playlist mutations have their own matrices.
tests/Integration/CatalogMutationFirewallTest.php crosses thirty routes (albums,
songs, movies, genres, covers, cover extraction, the admin deletes and previews, the
admin duplicates listing and the library routes) with anonymous, member, unrelated
member and administrator: every denied row asserts the role voter's refusal, an
unchanged catalog snapshot, empty in-memory transports, no storage call and no Redis
growth, and every administrator row asserts the change and the port calls it makes,
with scan-all, file deletion on a real library root, album deletion with a cover,
cross-library merge and path validation as their own rows. Its kernel,
tests/Fixtures/Auth/CatalogMutationOAuthKernel.php, replaces the Messenger
transports with in-memory ones and makes the storage port synthetic.
tests/Integration/PlaylistMutationFirewallTest.php crosses every playlist mutation
and both collaborator routes with owner, collaborator, collaborator with sharing
switched off, unrelated member, administrator and anonymous, each refusal asserting
the voter's message. tests/Unit/Shared/Infrastructure/Security/MutationRouteInventoryTest.php
holds the two suites' route keys to every write route under /api/ the router knows,
minus an explicit list of routes other suites own, so a new mutation route fails a unit
test until it has rows. The read suite also proves the denied-filter, related-resource
and anonymous-detail rows, and the delivery suite proves the signing endpoint's own
400 answers through the kernel.
The other real-firewall suites under tests/Integration/ are
OAuthFirewallAcceptanceTest.php (the production firewall with real RS256 tokens,
ES256 proofs and Redis replay storage: a bound proof reconstructs the user, a
replay is refused, an invalid presentation is refused), TrackStreamFirewallTest.php
(audio track streams: authorization before filesystem resolution, exact range bytes,
membership and token revocation between range requests, replayed proofs),
CatalogReadFirewallTest.php (library-scoped catalog reads and searches through
real membership), ImageReadFirewallTest.php, PlaylistReadFirewallTest.php,
ArtistMutationFirewallTest.php, LyricsFirewallTest.php, CatalogMutationFirewallTest.php,
PlaylistMutationFirewallTest.php and PushUnsubscribeFirewallTest.php (the
production OAuth and DPoP chain over the image, playlist, artist, catalog and library
mutation, lyrics and push unsubscribe routes with PostgreSQL adapters).
Atomic rate-limit storage
Configured rate limiters execute Symfony's installed fixed/sliding-window policies against operation-local snapshots and commit state through Redis compare-and-set. The focused regressions must distinguish an absent subject from failed or corrupt storage, preserve ordinary exhaustion responses, and refuse protected effects when state cannot be established. All 19 configured names and budgets remain in force. Container compilation rejects policies other than fixed and sliding windows.
Storage cases cover policy parity, negative token counts, state identity and TTLs, conflicting writers, uncertain writes without replay, per-subject reset and named generation changes. Redis checks snapshot age against its own clock, accepting no negative age or age over two seconds. Empty reads and resets retain ten-second revision tombstones so expiry cannot make a paused writer's old snapshot writable again. Conflict retries stop after eight attempts or a two-second monotonic budget; an in-flight bounded Redis call can finish after that budget expires.
Corruption cases distinguish recovery from a budget reset. Named clear replaces a valid old-generation hash on its next read, including extra fields, but wrong-type subject keys and missing/invalid hash generation fields still fail closed. They need expiry or targeted repair; the internal per-subject reset replaces the record. Configuration changes start fresh subject budgets because the effective configuration contributes to the subject digest. This proves configuration isolation, not a shared budget across a rolling configuration change.
Concurrency checks need independent PHP processes and connections sharing a barrier on disposable Redis. Fixed and sliding windows must admit exactly the remaining budget; namespace, subject and effective-configuration isolation and fresh-process observations of resets need separate assertions. Refused and paused Redis cases must fail within a bound instead of treating the outage as an empty bucket. HTTP checks cover generic API and pre-auth limits, password-reset/resend, cover extraction, Discovery and configuration validation before their effects.
Unavailable consumption answers a generic 503 with Retry-After: 1 and
Cache-Control: no-store; ordinary exhausted budgets keep their existing outcomes.
Logout and token revocation keep the generic API limiter's narrow availability
exception and all remaining authentication/proof checks. Administration checks
exercise the actual consumed namespace through HTTP and CLI, including unknown
names, unavailable clears and clear-all partial failure. Run these checks through
the disposable backend coordinator described below; do not pause or flush the
development Redis. The integration selection also observes resets through fresh
processes; its paused-Redis fault affects only that lane's disposable service.
bash scripts/test-backend-container.sh \
tests/Integration/RateLimiterConcurrencyTest.php \
tests/Integration/RateLimiterHttpAvailabilityTest.php \
tests/Functional/Console/RateLimiterCommandsTest.php \
tests/Functional/Controller/RateLimiterMonitorControllerTest.php
The unit regressions under tests/Unit/Shared/Infrastructure/RateLimiter/ cover
failure semantics, installed-policy parity, state decoding and Redis protocol
handling in RateLimiterFailureSemanticsTest, RedisRateLimiterFactoryTest,
RateLimiterStateCodecTest and RedisRateLimiterStoreTest. The narrow revocation
exception is covered by
tests/Unit/Auth/Infrastructure/Security/ApiRateLimitAvailabilityTest.php.
Current qualification results are recorded in the roadmap.
Provider destination policy
The provider tests use injected DNS answers and disposable TLS peers; they never contact metadata, radio-catalog or push providers. Run the focused selection in the CI image through the existing unit coordinator:
bash scripts/test-unit-container.sh \
tests/Unit/Shared/Infrastructure/Http/PublicDestinationPolicyTest.php \
tests/Unit/Shared/Infrastructure/Http/PublicHttpClientTest.php \
tests/Unit/Shared/Infrastructure/Http/PublicHttpTransportTest.php \
tests/Unit/Metadata/Infrastructure/Api \
tests/Unit/Radio/Infrastructure/Sync/IprdStationSyncAdapterTest.php \
tests/Unit/Notification/Infrastructure/Push
Policy cases cover HTTPS port 443, exact and suffix host boundaries, every DNS answer, mixed public/forbidden answers, mapped IPv4 and IPv6 translation/transition ranges. Transport cases check connection pinning, forced peer/name verification, proxy exclusion, fresh HTTP/1.1 connections, protected options and sanitized errors. CAA redirect cases cover approved and relative locations, credential removal on cross-origin hops, changed DNS, downgrade/private/unapproved targets and the three-redirect bound. Other provider requests refuse redirects.
Metadata adapter cases retain response mapping and failure behavior for all seven providers and Spotify token acquisition; IPRD cases cover summary/catalog requests. Push cases run the installed Minishlink/Guzzle path over persisted endpoints, preserve encrypted bodies and VAPID signing, reject 3xx as failure, retain 404/410 expiry handling and prevent the installed cURL rewind path from replaying a push. Logs must exclude API-key query strings and endpoint credentials.
PublicHttpTransportTest and PushTransportTlsTest route approved test names to
local TLS peers only in test transport wrappers, after inspecting production
pinning options. They check trusted success, untrusted certificates, hostname
mismatch and blocked redirect destinations. Production policy has no test-domain
or private-address exception. The historical MetadataStreamRedirectTest still
reproduces raw PHP stream behavior; adapter tests establish the new boundary.
DNS and HTTP share one absolute monotonic budget, capped at thirty seconds, through approved CAA redirect hops. Push shares its thirty-second budget between DNS and sending, with the connection timeout reduced to the remaining budget when that is less than five seconds. Adapter tests inspect the deadline and transport options after DNS, including exhausted budgets that must make no HTTP call. Existing provider retries can extend the overall operation.
Bounded native DNS
NativeDnsResolver runs the complete native A/AAAA lookup in a fresh PHP helper.
The caller waits through coroutine-aware polling; success still requires the
whole reply and the destination policy still classifies every address. The native
semantics distinguish definitive no-data from a failed family; a failed family
cannot turn the other family's public result into success.
Run helper lifecycle tests through the native lane and real system-resolver checks through their dedicated disposable container:
bash scripts/test-unit-container.sh \
tests/Unit/Shared/Infrastructure/Http/NativeDnsResolverTest.php \
tests/Unit/Shared/Infrastructure/Http/NativeDnsResolverRuntimeTest.php
BAANDER_TEST_IMAGE=baander-ci-prewarm:local bash scripts/test-dns-runtime-container.sh
Both PHPUnit files belong to scripts/backend-native-tests.json because they
execute real process and signal lifecycles. They check malformed/oversized output,
nonzero exits, capacity shared across instances, timeout and cancellation cleanup,
repeated descriptor/capacity recovery, inherited process accounting and closed
parent descriptors. The fresh native lane runs as www-data with networking
disabled.
The DNS drill uses a privileged container, as the native lane does, with a
180-second outer timeout. Root changes only that disposable container's
/etc/resolv.conf and starts a loopback DNS fixture on port 53; probes
and production helpers run as www-data. The container has --network none and
is removed on exit. No host resolver, production DNS or development database is
used. The fixture covers both-family success, definitive AAAA no-data, family
SERVFAIL, a private answer beyond Swoole's address limit with native TCP fallback,
and stalled DNS with process hooks enabled and disabled. A heartbeat must advance
while lookup waits, no HTTP request may start after budget exhaustion, and cleanup
must leave no owned child. A subreaper fixture kills a lookup's parent and observes
the orphaned helper's finite lifetime.
The implementation admits at most eight owned children per PHP process, including unreaped children, and accepts at most 64 KiB of output. It polls at five milliseconds, shortening the sleep near the deadline with a one-millisecond minimum. Timeout/cancellation kills the helper and allows up to 250 milliseconds for cleanup; operating-system scheduling adds tolerance, and an unreaped child keeps its slot. The helper alarm rounds remaining time up to whole seconds and caps it at thirty seconds. These local checks do not certify live providers, external trust stores, a hard real-time return bound or an overall retry deadline. Current qualification results are recorded in the roadmap.
Running Tests
Workflow orchestration requires PHP 8.5+ with Swoole, Composer, Bash and GNU
coreutils on the host. Process groups also require setsid. Generate the standalone
loaders with composer tooling:autoload; this installs the Composer projects under
scripts/ and relay/ without booting the application kernel. Production worker
images install only the standalone scripts project's loader without development
dependencies, alongside the application's separate production loader. Worker and
HTTP fixtures select the matching Composer boundary; host-source archives carry
the standalone loader required by tooling entrypoints.
With the root development dependencies installed, run the workflow checks from the repository root:
composer run-script --timeout=7200 test:tooling
composer test:registry-subjects
composer analyse:tooling
composer check:tooling-format
composer check:tooling-architecture
The tooling suite uses phpunit-tooling.xml.dist and remains separate from the
four application suites. composer run-script --timeout=7200 test:tooling excludes the
registry-python-subject group. composer test:registry-subjects runs that group
against the unchanged registry backup service and enrollment issuer; it requires
Python 3 for those programs under test. Set REGISTRY_SUBJECT_PYTHON to select
their interpreter. Both suites are mandatory CI gates. Test and analysis commands
install the standalone loaders before running. Use composer format:tooling to
apply the scoped PER-CS3 formatting rules before checking them.
composer check:tooling-architecture combines composer deptrac:tooling with
composer check:tooling-source-policy: dependency and source checks reject reverse
CLI dependencies, raw process/Docker bypasses, manual implementation loaders and
duplicated image/layout defaults. These commands describe the required gates;
passing results and live qualification limits belong in the roadmap.
CI also runs the harness suite in the tooling runtime from
docker/runner/Dockerfile, without Python, pcntl or posix. The
docker/runner/tooling-test.Dockerfile copies source and Composer dependencies
from the application CI image, so this check needs no host bind mount.
Use an application image that satisfies the current Composer lockfile. To build one locally and select it for the disposable runners:
php scripts/build-application.php --buildx --target ci --tag baander-ci:local
export BAANDER_TEST_IMAGE=baander-ci:local
The build entrypoint supplies the canonical APP_HOME and image layout metadata
from the selected source and verifies the resulting image. Direct runtime-image
builds without that contract fail. The
tooling package reference
lists role image overrides, Docker selector precedence and finite budgets. Supported
configuration is captured once per invocation; nested hooks use the same resolved
target and image selections.
The default image reference in the runners may require registry access. An existing locally qualified image can be selected with the same variable. Check its PHP extensions and locked Swoole version; host PHP is not a substitute unless those requirements pass. The unit and messaging runners stream the current checkout and installed Composer dependencies into an isolated container. Image selection does not select an older copy of the application source.
The shared backend coordinator runs all four suites when no selection is given:
bash scripts/test-backend-container.sh
bash scripts/test-backend-container.sh --testsuite Unit,StaticAnalysisRules
bash scripts/test-backend-container.sh tests/Integration/RefreshTokenGraceTest.php --filter test
bash scripts/test-backend-container.sh --coverage-text=coverage/report.txt --coverage-clover=coverage/clover.xml
It requires the host tooling baseline above, Docker and the Docker Compose plugin.
BAANDER_TEST_PROCESSES defaults to 2 and accepts integers from 1 through
8; 1 is the diagnostic setting. BAANDER_TEST_TIMEOUT bounds the complete
run, including setup, and defaults to 3600 seconds. Cleanup uses a separate
shared total budget, defaulting to 120 seconds (BAANDER_CLEANUP_TIMEOUT), after
interruption or timeout. Unresolved ownership receipts remain in the run artifacts;
keep them and their saved transport material for cleanup against the recorded
daemon. Cleanup failure fails an otherwise successful run. Messaging has a finite
600-second catalog budget, overridable through tooling.budgets.messaging.
BAANDER_TEST_IMAGE selects the application runtime, and BAANDER_TEST_POSTGRES_IMAGE selects the
extension-capable database image. CI uses BAANDER_TEST_CHECKOUT_IN_IMAGE=1
with its freshly built image; local runs copy the current source and installed
dependencies into each container.
The coordinator discovers the selection once, then runs three phases. Ordinary
unit and rule tests use ParaTest workers recycled after 25 files. The explicit
native manifest, scripts/backend-native-tests.json, assigns Swoole, fork,
signal and fixed-resource tests to one worker in a privileged container with
networking disabled; that worker starts afresh for every file. Add new native
tests to this manifest. Functional and integration files are distributed
round-robin across at most the requested number of independent
app/PostgreSQL/Redis stacks declared in compose.test.yaml. Each stack uses a
unique disposable Compose project and runs one worker with a fresh process for
every file. Scheduling stays at class/file level; ParaTest's method-level
--functional mode is not used.
SegmentEncoderMediaTest generates deterministic colour patches, probes them,
encodes through the actual movie command builder and independently decodes the
published fragment. H.264 and HEVC cases compare interior Y/U/V samples at the
same pixel format and range, covering fresh output and a nonzero source start.
BT.2020 SDR must retain its primaries, transfer, matrix and range without tone
mapping; PQ and HLG cases require decoded software tone mapping to BT.709.
This covers transfer-based HDR classification and the software encode path, not display-gamut certification, a complete HDR reference-vector suite, Dolby Vision or hardware acceleration. Native colour checks do not establish new HTTP or browser playback acceptance.
SegmentEncoderSourceSelectionMediaTest generates a moving movie with distinct
attached artwork and checks both cover-first and cover-last stream ordering with
FFprobe. It runs the production probe, filters and command builder, reads the
published presentation, then independently decodes each initialization/media pair.
Fresh 14-second output and the eight-second tail after source second six must
retain the movie dimensions, frame rate, duration and changing frames. A cover-only
source must fail the required video mapping and publish no movie fragments.
These cases qualify native source selection through publication; they do not add
browser playback acceptance or establish CMAF conformance.
SegmentEncoderAudioProfileMediaTest covers Opus and AAC on fresh 14-second
output and the eight-second tail from source second six. Each case requires
closed MP4 video/audio tracks with continuous native timelines, checks the actual
MPD and initialized codec, and independently decodes every published fragment.
Audio must retain stereo at 48 kHz. Decoded duration allows 50 ms per fragment
and 100 ms overall for priming and padding. Video colour markers and audio
frequency changes distinguish the requested source interval from an incorrect
restart; positive audio energy and changing video frames are required.
SegmentEncoderSparseMediaTest covers 14 sparse movie cases. Software H.264
and HEVC retain the covering red, green or blue source frame on fresh output
and restarts at source seconds six and 24. Other cases cover fractional cadence,
a nonintegral final hold, video-only output including a silent six-second restart,
and audio delayed by eight seconds in MOV. Independent fragment and HLS decoding
checks continuous timelines, zero-based one-second video timestamps, the complete
held-frame sequence and positive audio signal. Every growing playlist prefix and
completed playlist must retain the seven-second target. Two paced H.264/HEVC
cases require independently decodable video and audio, followed by a larger video
publication edge while the encoder remains running and the presentation remains
incomplete. The later edge must arrive before second 29 of a thirty-second paced
input, leaving a one-second margin before EOF to exclude final-flush output.
Each producer has a 45-second process bound.
The sparse path applies only to finite positive probed rates below one fps and normalizes video to one fps. Transitions round upward so future content is not shown early; terminal extension must be nonnegative and less than one second, with a small numerical tolerance. Delayed-audio tests require approximately two seconds of remaining delay after a six-second restart. Their indexed MOV source establishes video origin zero. This does not establish a general repair for Matroska sources whose reported start time disagrees with their video packets, or arbitrary variable-rate gaps.
SegmentEncoderTimestampOriginMediaTest covers 19 sparse Matroska source-origin
cases through software H.264/HEVC output. Verified origins zero and four, delayed
and matched audio, fresh output and restarts at source seconds six and 24 must
retain the full colour holds, duration, audio delay and subtitle source interval.
Three audio-first cases require the first movie packet after packet sixteen and
within the bounded 128-packet prefix. Independent fragment/HLS decoding and
production subtitle extraction check media clocks and embedded cues. Two Opus
cases supplement the AAC controls. Complete HLS audio decoding must retain the
full presentation duration, near-silent initial samples and audible tone onset;
a correct MPD delay or MP4 edit list alone cannot satisfy this check.
The shared video probe reads at most 128 packets across all streams. Origin evidence comes only from the first observed packet of the selected non-attached movie stream. The qualified source is Matroska H.264 with explicitly zero B-frames and finite positive cadence below one fps. Its first packet must be a key packet without a discard flag and have finite nonnegative PTS; reported format start must be at or after that PTS and format duration must be greater. A mismatched reported start or positive verified origin selects an explicit common timestamp anchor. Matched zero origins keep the existing path.
For this Matroska path, duration is normalized once by subtracting the verified origin. Media encoding seeks in absolute timestamps and shifts video and audio by the same origin-plus-source-position offset. Subtitle extraction subtracts only the origin; existing timeline rebasing then applies the requested seek. With a verified anchor, a positive sparse restart before one nominal frame interval reads from the file beginning instead of input seeking, preserving leading physical audio while retaining the requested offset and trimming. Later restarts keep fast input seeking. The nominal interval is not a proof of general variable-rate behavior. Source-origin qualification remains unavailable for missing movie-packet evidence, malformed metadata, negative origins, B-frame input, other containers and general variable-rate gaps.
Every movie with audio applies aresample=first_pts=0 after any negative-preroll
trimming and profile filters, whether or not the probe has a verified timestamp
anchor. Positive leading delay becomes actual encoded silence; negative codec
priming is trimmed without asynchronous compensation. Ordinary sources retain
their existing source-origin and seek commands, and video-only output gains no
audio options. HLS therefore receives actual silent samples before the intended
tone instead of relying on an MP4 edit list. The audio-only transcoder is outside
this movie-path normalization.
For the qualified cases where selected audio ends at or before video,
apad=packet_size=1024 follows normalization and output -shortest bounds padding
to the finite video. When selected source audio ends early, real silence must
cover the remaining video, including sought output with no source audio packets
left. The producer must terminate without losing the video tail. Video-only
commands omit both padding and -shortest; separate subtitle helpers do not set
duration. Longer-soundtrack endpoint resolution uses qualified selected-video
metadata; missing or invalid evidence retains the container estimate.
One FFmpeg process reads the same source through two inputs when audio exists. Video input zero retains hardware options, fixture pacing and source selection; unpaced audio input one has two decoder threads and disables video, subtitle and data decoding. The selected audio ordinal maps from input one. Both readers share the logical source position and verified-origin translation. Sparse audio requests one nominal source-frame interval before the generation start, clamped to zero; a zero-relative position omits its input seek. Sparse audio retains preroll when it seeks. Unanchored audio receives the negative difference between the generation start and its earlier input seek, preserving the generation clock. Verified-origin audio keeps the existing common offset, and existing trimming removes negative local samples. Video seeking and ordinary accurate audio seeking are unchanged. Cue alignment may decode farther back than the requested nominal interval, so it is not a universal I/O bound. Independent audio EOF lets padding publish playable fragments while video remains active. Video-only commands retain one input; cancellation stops the one process and both source reads.
SegmentEncoderAudioDelayMediaTest covers sixteen ordinary 25-fps MOV cases
with video beginning at zero and no verified source origin. H.264/HEVC output
and AAC/Opus profiles cover fresh output and six-/24-second restarts of a
thirty-second source with audio delayed eight seconds. Independent fragment and
HLS decoding must retain the full video grid, held colour intervals, continuous
tracks, complete PCM including silence, measured tone onset and the audible tail.
Controls cover 44.1 kHz AAC with DRC, matched PCM audio and encoded AAC with
negative initial priming timestamps. Matched-source waveform correlation and
bounded lag check content alignment without requiring byte-identical PCM.
Ordinary B-frame timestamps are checked from bounded native MP4 decode times, sample durations and signed composition offsets, with the actual timescale and unshifted initialization edit. FFprobe timestamps remain diagnostic evidence; subtracting its first reported PTS must not conceal a displaced presentation.
SegmentEncoderDefaultAudioMediaTest drives the production probe, stream
manager, encoder and HLS renderer with distinguishable source audio. Commentary
is 220 Hz, mono/44.1 kHz/English; later default dialogue is 880 Hz,
surround/48 kHz/Danish. Selection uses the first marked default in discovery
order, falling back to the first audio track when none is marked. Multiple
defaults choose the first marked track; no audio has no selected ordinal.
The ordinal counts audio streams and does not use the global FFprobe index.
Discovered order stays intact, while raw/serialized probe reconstruction and
direct construction derive the same selected codec, channels, sample rate and
layout for filters and mapping. Native MPD and production HLS/DASH manifest
checks establish selected language; independent HLS probes check actual codec,
sample rate and channels alongside decoded tone identity.
Independent fragment and full HLS decoding require the selected tone and complete fresh/seek-six tails for AAC and Opus. No-default and multiple-default controls, eight-second delayed dialogue and delayed unselected commentary check selection and timing together. Fixture preflight verifies actual defaults and layouts: MOV PCM supplies the marked-default cases, and Matroska FLAC preserves an explicitly absent default. The chosen audio keeps its own timestamps and existing leading-silence normalization; video-origin qualification remains independent.
SegmentEncoderShortAudioMediaTest drives the real probe, stream manager,
encoder and presentation reader with fourteen-second video and short source
audio. Independent video-fragment and full HLS PCM decoding require complete
decoded video, including frame count, colour sequence and track duration, plus
tone before the audio boundary and measured silence through the tail.
AAC/Opus fresh output and exhausted-audio seeks, leading delay plus trailing
silence, a selected second track ending before an unselected track and matched
audio controls check selection, coverage and bounded termination.
An encoded AAC source control verifies a restart after its final source packet.
Two paced AAC/Opus cases require increasing, independently decodable joint
video/audio prefixes while the presentation remains dynamic and the producer
runs, followed by complete final tails. A cancellation case stops the same
producer after playable prefixes appear. Receipts retain publication timing,
closed audio/video lead and process memory; these fixture measurements do not
establish general resource guarantees. A separate longer-soundtrack audit shows
the final producer reaches the actual video endpoint. Current probe duration
uses qualified selected-video timing when available; the earlier container-based
failure is retained as historical evidence, and the estimate fallback remains
unqualified for exact-end behavior.
SegmentEncoderVideoEndpointMediaTest covers fourteen selected-video endpoint
cases through the real probe, stream manager, encoder and presentation reader.
MOV/Matroska sources with longer audio, B-frames, delayed video, positive starts
and sparse held frames must retain complete decoded video, bounded audio and
exact-end coverage using the DTO duration. Fresh and near-end AAC/Opus cases
check source identity, content and finite process completion. Distinct audio
markers at source seconds 20 and 26 verify the clock through sparse seeks,
including the retained Matroska cue-layout regression.
The Selected video endpoint uses
the same first non-attached movie stream as encoding. For MOV/MP4, its start at
or after the normalization origin plus its positive duration span gives the
endpoint. Valid rational time-base values take precedence over textual seconds,
allowing only FFprobe's decimal rounding differences and rejecting
contradictory metadata. Matroska uses the selected track's strictly parsed
DURATION tag as an absolute endpoint; inferred stream duration is not
independent evidence. Normalize once by the verified source origin, or by an
ordinary finite nonnegative format start without inventing an origin. A
qualified result must be finite and positive.
Missing, malformed or unqualified timing retains the prior container-duration estimate, including its existing verified-origin adjustment. That fallback does not establish an exact video end. The bounded 128-packet prefix cannot determine an unknown endpoint; no second probe, full-file scan, frame-rate inference or endpoint cache is added. Serialized duration preserves the resolved value without another origin subtraction. Fresh worker playback metadata changes; existing catalog records are not backfilled or reset, and native manifest clocks stay based on actual media.
Each stack owns its cache, temporary files, sockets, database, Redis server and
OAuth keys. No writable host mounts or published ports are used. Database and
Redis DSNs are exported so nested PHP processes reach the same stack. Each
database runs migrations twice. Setup uses root inside the disposable container;
tests run as www-data, preserving permission checks. Test worker PHP processes
receive an explicit 512 MiB memory limit. Coordinator and reporting PHP
processes receive 2 GiB because full-suite serialized coverage needs more memory
than individual test files.
JUnit validation requires every discovered class, method and dataset label
exactly once. Crashes, missing reports, unexpected identities and configured
strict failures fail the run. Skipped or incomplete cases remain observed
identities without suppressing PHPUnit's failure policy. The coordinator pins
one random seed across lanes; use --random-order-seed to reproduce it. Custom
configurations preserve relative paths and source filters, but suite-specific
bootstraps are rejected.
Logs, manifests, JUnit and serialized coverage remain under the printed
var/test-runs/<run>/ directory, including failed runs. Set
BAANDER_TEST_ARTIFACTS to choose a different parent directory. Containers,
networks and volumes are removed on completion, timeout or interruption, and
cleanup failure fails the run. Service stacks use bounded
docker compose down --volumes --remove-orphans after collecting diagnostics.
Final coverage is published only after identity and artifact checks; merging
retains uncovered executable lines and exclusions. Validate the standard reports
with php scripts/validate-backend-coverage.php coverage before using
coverage/metrics.json.
Runner regressions use composer run-script --timeout=7200 test:tooling and the
BackendTestRunnerTest PHPUnit file. The destructive isolation probe,
php scripts/test-backend-stack-isolation.php, creates two disposable stacks
to check database outages, Redis pause/flush, cache/socket separation and
inherited DSNs. Runtime security drills remain separate serial commands.
For qualification,
php scripts/compare-backend-runs.php <reference> <candidate> compares
selected and completed identities, seed, timings, coverage denominators and
exact executable/covered line sets. Coverage validation and comparison require the
PHP DOM extension. Coverage comparison also uses the root Composer dependencies
to read serialized artifacts.
Full coverage qualification matched 9,098 selected/completed test identities, 44,191 executable lines and 35,385 covered lines between the sequential reference and ParaTest. Use the run comparison command above to inspect timings and identity or coverage differences in retained artifacts.
Validated asynchronous workloads
config/packages/messenger.php declares seventeen production workloads: fifteen
default routes and explicit-only FetchLyricsCommand and SendWebhookCommand
entries. The test configuration adds FailureTransportProbe and uses in-memory
transports for async and scheduler. Resource classes io, cpu and coordination describe
work; they do not allocate capacity.
Catalog and compiler-pass unit tests cover invalid workload declarations, codec
registrations, effective default routes, transport-eligible handlers, JSON
serialization and bounded retry policies. AsyncWorkloadWiringTest boots the real
container to check the compiled catalog and sender-locator decorator.
ValidatedSendersLocatorTest checks actual sender selection, including explicit
transport stamps, interface/wildcard routes, rejected fan-out and unrouted inline
commands. Received deliveries retain normal handling and manual failed-message
retry behavior. Lyrics remain synchronous unless explicitly selected for async.
The notification delivery relay decodes committed intents, resolves their channel
and selects async explicitly, including webhook delivery. Relay-intent coverage
checks that these produced commands belong to the admitted catalog.
WorkerTransportConfigurationTest checks canonical Redis URL forms and rejects
stream, group and consumer overrides, ambiguous query options and alternate
transports. Controller and supervisor tests verify admission before Docker
creation or child launch, preserved catalog receiver order and generated consumer
identity. These checks establish configuration behavior rather than connectivity,
network discovery or measured capacity. The messaging and worker-command runners
below exercise the corresponding container and runtime paths.
Messenger without Swoole
To check Redis fallback with the Swoole extension unavailable, use the same CI
image and disposable coordinator. Set BAANDER_TEST_IMAGE to the application CI
image and BAANDER_TEST_POSTGRES_IMAGE to the project's PostgreSQL image before
running:
BAANDER_TEST_DISABLE_SWOOLE=1 bash scripts/test-backend-container.sh \
tests/Fixtures/MessengerWithoutSwooleTest.php \
tests/Unit/Shared/Infrastructure/Messenger/SwooleFallbackHandlerRegistrationTest.php \
tests/Integration/MessengerJsonDeliveryTest.php
The coordinator removes the dedicated Swoole loader inside each disposable app
container before discovery or console boot. It compares PHP configuration hashes
and loaded extensions before and after removal, requiring every other extension
and setting to remain unchanged. Runtime evidence stays beside the lane logs as
<lane>.runtime-before.json, <lane>.runtime-after.json and <lane>.runtime.log.
The option accepts 0 or 1 and defaults to 0.
The dedicated fixture verifies absence in the ParaTest worker and a nested PHP process, and checks synchronous execution identity, recursive locking and service pool reuse without the extension. It lives outside the default suites because it deliberately fails when Swoole is loaded. The selected tests exercise transport-restricted handler registration, real Redis delivery, retry and dead-letter handling. Service setup also clears the Symfony cache and runs migrations twice without Swoole. CI runs this selection as a blocking step. This qualifies the console and Messenger paths exercised here; the web server still requires Swoole, and the Composer extension requirement remains unchanged.
Run the backend runner regressions before changing orchestration or cleanup:
composer run-script --timeout=7200 test:tooling
bash scripts/test-worker-operator-cleanup.sh
CI runs the PHP tooling suite and the operator cleanup script as separate steps. The operator cleanup script uses a disposable Docker CLI stub to check enumeration failures, removal failures, retained resources, browser-hook cleanup and preservation of an earlier failure exit code. It does not prove cleanup against a real daemon; the production operator drill must still verify absence of its containers, networks and volumes.
For strict unit and messaging checks, run from the checkout:
bash scripts/test-unit-container.sh
bash scripts/test-messaging-container.sh
bash scripts/test-worker-retirement-container.sh
bash scripts/test-worker-recovery-container.sh
bash scripts/test-worker-command-container.sh
bash scripts/test-worker-operator-container.sh
BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-worker-lifecycle-container.sh
bash scripts/test-docker-context.sh
bash scripts/test-worker-containment-container.sh
bash scripts/test-functional-container.sh tests/Functional/Controller/FavoritesControllerTest.php
bash scripts/test-functional-container.sh tests/Integration/CoverExtractionPersistenceTest.php
bash scripts/test-functional-container.sh tests/Integration/CoverlessAlbumKeysetTest.php
bash scripts/test-functional-container.sh tests/Integration/WorkerDeploymentLeaseSchemaTest.php
Both unit and functional wrappers invoke the shared backend coordinator. They
require the host tooling baseline above, Docker, the Docker Compose plugin and
installed application Composer dependencies. The unit wrapper defaults to
Unit,StaticAnalysisRules; filters keep that selection, a later explicit
--testsuite overrides it, and file paths follow
PHPUnit's selection rules. The functional wrapper defaults to tests/Functional
only when called without arguments; otherwise it forwards the supplied selection,
including integration paths.
Both wrappers use 512 MiB test workers, 2 GiB coordinator/reporting PHP processes
and the default 3600-second overall timeout. Ordinary and native unit lanes have
networking disabled; functional and integration selections use disposable Compose
stacks. phpunit.xml.dist randomizes test order within dependency constraints and
treats unexpected output as risky.
The operator runner requires the host tooling baseline above and Docker. Its
default host mode also requires the operator PHP runtime with posix and
pdo_pgsql (BAANDER_TEST_PHP_BINARY selects its executable) and a local Unix
target selected through the
shared Docker configuration.
The host controller reaches a loopback-published database port; the worker reaches
the same database through its private network. Its outer 240-second timeout bounds
native database waits; DBAL connect_timeout is not a hard connection deadline.
BAANDER_TEST_OPERATOR_IN_CONTAINER=1 runs the entrypoint in a separate trusted
controller container with PHP and a verified static Docker CLI. The CLI comes from
the catalog's operatorHelper role (BAANDER_TEST_DOCKER_CLI_IMAGE overrides it).
Container mode requires BAANDER_TEST_DOCKER_SOCKET_PATH: the Unix socket path on
the selected Docker daemon's host, used as the source of the controller's bind
mount. The examples use /var/run/docker.sock for a standard rootful daemon.
For rootless Docker or a forwarded socket, replace that value with the actual
daemon-host source path. A socket path visible inside the outer runner may differ
from that source path.
Only the controller receives the daemon socket mount. Its SELinux process label is disabled to access that socket without relabeling it; this exception applies only to the trusted test controller. Credentials are streamed into private files owned by its user, and it reaches PostgreSQL on a separate controller network with no published database port. The controller verifies that its socket reaches the same daemon before issuing an operator action. The worker keeps its private network and receives only its admitted named media volumes, with no Docker authority. Version 4 manifests require a complete autoscaling profile, separate control/execution budgets and disjoint CPU sets, read-only source and writable derived volumes, and a private IPC volume. Each unit has separate memory/CPU/PID ceilings, including encoder threads; see the operator recipe. With no explicit override in the shared Docker configuration, setup captures the runner's ambient Docker connection. The explicit socket mapping supplies the controller's daemon-host bind source when the runner is itself a container.
The paired operator fixture requires at least four host CPUs and 13 GiB of host memory. Combined web/media and control-capacity runs require 15 GiB: 7424 MiB for the one-slot worker pair, 4352 MiB for PostgreSQL/Redis (4096/256 MiB), 2048 MiB for web and 1024 MiB of headroom. The standalone sum is 12800 MiB; adding web raises it to 14848 MiB, rounded up to the stated host requirements. These are fixture admission ceilings, not measured universal capacity defaults.
Both modes exercise bin/worker-deployment.php create, reconciliation, start,
status and recovery against disposable PostgreSQL/Redis with canonical private
credentials. They verify real outbox delivery, scheduler work, lease/child
identities and retirement of the immutable boot. Earlier qualification passed both
modes, including container mode from a workspace with only the former runner script
and no host PHP or Composer dependencies. The PHP orchestration now requires the
host tooling baseline in both modes. The fixture image does not certify the
production build.
Forgejo now includes a
blocking operator lifecycle step using container mode and
BAANDER_TEST_CHECKOUT_IN_IMAGE=1 to copy source and dependencies from the built
image. Its quality-gate job sets BAANDER_TEST_DOCKER_SOCKET_PATH from the
repository variable of the same name, defaulting to /var/run/docker.sock. Set
that variable for a runner with a different daemon-host socket path. Adding that
gate does not establish a passing Forgejo run. Status does not certify readiness.
The standalone lifecycle runner pauses Redis and PostgreSQL in separate disposable operator projects, with no HTTP container and an ordinary maximum of two:
BAANDER_TEST_IMAGE=baander-ci:test BAANDER_TEST_OPERATOR_IN_CONTAINER=1 \
BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-worker-lifecycle-container.sh
Use the application CI image built for the current checkout. The runner takes no
arguments, defaults to container controller mode and uses the operator runner's
Docker, image and PostgreSQL settings. BAANDER_TEST_CHECKOUT_IN_IMAGE=1 selects
source and dependencies from the image. BAANDER_TEST_ARTIFACTS selects the evidence
directory; otherwise it creates var/test-runs/worker-lifecycle-*. Each phase
writes qualification.json, cleanup.json, exact lease/work receipts and process
observations beneath its own subdirectory, with a sibling phase log. The outer
timeout is 1300 seconds, followed by a 40-second kill grace.
With Redis paused, status must report unavailable queue measurements while controller evidence and deployment authority remain fresh. The fixture commits an identified outbox event and manual scheduler occurrence, observes their unfinished state, then sends TERM to the control unit's PID 1. Both units must stop within 50 seconds of TERM, before Redis is restored. This checks explicit shutdown during blocked transport I/O; Redis observation loss alone does not revoke PostgreSQL lease authority. The Redis server identity must survive the pause. With PostgreSQL paused, the fixture sends no stop signal: failed lease renewal must drain both units automatically within 50 seconds of the fault. Neither phase permits a restart or OOM kill as its shutdown evidence.
After restoration, the predecessor's exact boot and epoch still reserve ownership. A different boot cannot replace it until recovery records removal of both captured immutable unit IDs. Repeated recovery must remain idempotent. The successor uses a fresh boot and epoch two; delayed predecessor recovery, renewal and start must leave that successor intact. The Redis phase checks the exact work retained before shutdown. The PostgreSQL phase seeds work after successor admission, because it cannot assume earlier work remained unconsumed. After the stale predecessor operations, both phases seed a fresh outbox event and manual occurrence and require their completion under the successor. Each work batch has a 90-second deadline.
Completion requires the identified outbox event, one notification projection,
both intended channel deliveries and one returned scheduler execution under the
successor's exact authority, with no failed messages or residual stream, pending
or delayed entries. The scheduler publication receipt dispatched_at can remain
null when Redis accepted a send whose reply was lost before the old worker stopped;
dispatch_token and the exact returned effect establish this check's completion.
An execution already consumed without a return receipt remains uncertain and
cannot be replayed merely to obtain a completion marker. This drill does not
establish exactly-once delivery, broker crash durability, automatic replacement,
unconditional transport I/O deadlines or new autoscaling performance results.
Cleanup restores any paused dependency and verifies absence of run-owned resources;
cleanup cannot substitute for the earlier shutdown and retirement observations.
CI runs this same container controller path as a blocking step; the configuration
alone is not evidence of a completed remote run.
To check the actual production worker target, build it separately and pass its local image reference to the production runner:
php scripts/build-application.php --buildx --target worker --tag baander-worker:test
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-worker-production-container.sh
The runner has a 300-second outer deadline; image build time is outside that bound.
It verifies the worker user, entrypoint, disabled web health check and production
environment and disabled PHP error display; checks that development dependencies,
baked OAuth keys and agent artifacts are absent; and runs Composer's production
platform checks. A real coroutine file read/write smoke test runs against the
immutable image with default Docker seccomp, all capabilities dropped and
no-new-privileges. This catches an io_uring-enabled Swoole build that cannot use
its native file backend under those restrictions. The supported image builds
Swoole without io_uring; no runtime INI switch substitutes for that build choice.
It then uses
BAANDER_TEST_USE_IMAGE=1 with container controller mode to run the lifecycle
against that exact immutable image. This skips the fixture rebuild and injects no
source, dependencies, keys or cache into the worker. Test preparation and Docker
authority remain in separate containers. The scope is the worker lifecycle,
outbox and scheduler, without OAuth certification. Forgejo now includes blocking
context-exclusion, production-worker build and production-lifecycle steps. Their
configuration does not establish a passing Forgejo run. The recorded immutable-image
lifecycle and combined Chromium playback/recovery gates passed before the paired
deployment change. Retain image identity, phase, containment and cleanup evidence
with the runner's artifacts before claiming a result for a newer deployment.
The production recipes select PHP 8.5.12 and Composer 2.11.1. Native Swoole is
pinned to Composer's required 6.2.4. PIE installation failures fail the image build.
The combined two-command runner exercises shared media with the production worker image:
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-two-command-deployment-container.sh
It has a 900-second outer deadline. For an attached browser checkpoint, install web dependencies and provide the pinned Playwright Docker image, then enable the optional hook:
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
BAANDER_TEST_TWO_COMMAND_CHECKPOINT="$PWD/scripts/test-two-command-browser-checkpoint.sh" \
bash scripts/test-two-command-deployment-container.sh
The hook uses the configured browser image role, Docker host networking and the
checkout's playwright-core dependency. A custom BAANDER_BROWSER_IMAGE requires
BAANDER_BROWSER_PLAYWRIGHT_VERSION; preflight checks that declaration against the
installed package before browser creation. The default reference and compatible
version live in the tooling catalog. Set
BAANDER_TEST_BROWSER_ENGINE to chromium (the default) or firefox; unknown
values fail before the deployment runner creates resources. Chromium keeps its
channel: 'chromium' launch option; Firefox uses its own engine. Both run
headless. Receipts retain the actual engine and version, or the requested engine
and error if launch fails.
The preflight checks H.264/AAC support through Media Source Extensions and records native HLS support separately. Playback uses the product's HLS.js player through MSE, without media feature overrides. A capability report or HTTP response does not prove decoding: the gate requires current-generation delivered media, decoded frames, an advancing clock and real seekable ranges. Growing movie playback waits for newly published media rather than moving its clock to a live sync point; the source cursor remains authoritative.
Paused restoration retains the browser's raw frame counter. If it is zero, a fresh draw of the current video into a 16-by-9 canvas can establish the fixture's expected colour pattern. The sample is tied to the current element, source, media time and dimensions. This pixel fallback rejects blank and black samples; a positive raw counter remains sufficient frame evidence. The fallback is specific to this fixture and covers Firefox displaying a paused decoded image while reporting zero quality-counter frames. Running playback still requires the raw frame counter to increase alongside the clock.
Set BAANDER_TEST_BROWSER_CLIENT_CONTROLS=1 to include client controls. With that
flag, each engine runs all eight phases: authentication, attachment, client
controls, paused seek, web restart, worker containment, worker recovery and
completion. Each run uses the 900-second outer deadline. CI runs Chromium and
then Firefox against the same built worker image, with separate
var/test-runs/two-command-chromium and var/test-runs/two-command-firefox
artifacts. Run the local engine checks sequentially as well; use
BAANDER_TEST_ARTIFACTS to keep their evidence separate. CI configuration alone
is not evidence that either browser passed.
Each subtitle-continuity scenario selects its choice once through the TextTrack API, then only observes modes and active cues. The nondefault Forced track must remain exclusively showing through a source seek and paused capability renewal; Off must leave every subtitle track disabled through a paused seek and running renewal. Selection follows the source stream key, preserving identity between tracks that share a language. The remembered choice belongs to the current movie playback owner and is not durable across page reloads. If a chosen track is temporarily absent, captions stay off while its identity is retained.
Screenshots support inspection, but these checks do not automate the native caption menu or prove caption pixels. Safari/native HLS remains unqualified. Choosing Off when every available track is already disabled creates no observable TextTrack mode change; in that case an absent-track preference remains retained.
After worker recovery, a 60-second completion/replay check seeks near the known source end through the real source slider and plays the tail at normal speed. It requires a genuine native ended event, completed current-set audio/video playlists and delivered final fragments. The ending pause is dropped before forwarding; at least two fresh canonical running polls and the attached signed capability's actual expiry must leave the same completed frame and cursor stopped. Native Play must then submit one source-zero seek, attach a new execution and receive current-generation media with advancing time and raw frame counts. The check does not force the clock to EOF, synthesize ended or accelerate playback. It qualifies completion after a user seek, not uninterrupted full-source playback or byte-exact decoding. The eight phases and phase deadlines remain unchanged; the outer deadline is 900 seconds.
The production fixture also submits a captured old-generation native seek after an explicit paused seek, while the worker is live and while it is absent. It must leave desired position, execution generation and control revision unchanged. This checks the server's fence independently of whether a browser reproduces the race.
The hook retains browser evidence with the drill artifacts and removes its browser container during cleanup. The standard runner does not require this hook. The recorded gate before the paired deployment change passed authenticated playback, paused seek, web restart, worker containment and recovery, and final completion. It also proves both stale-generation probes leave paused state unchanged, checks fourteen contained process identities with no survivors and verifies cleanup. Ordinary autoscaling and saturated media controls have dedicated gates below; regional qualification remains separate.
The reserved-control pressure drill runs separately from the full backend suite:
php scripts/build-application.php --buildx --target worker --tag baander-worker:test
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_OPERATOR_IN_CONTAINER=1 \
BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-worker-control-capacity-container.sh
It uses disposable services and a version 4 paired deployment, with control 2944 MiB/129 PIDs on one available CPU and execution 4480 MiB/256 PIDs on two other available CPUs, including 320 MiB for scheduled console execution. The runner selects the first three available CPUs and records their explicit sets in the manifest; its host preflight requires four CPUs and 15 GiB, including the web/dependency ceilings and headroom described above. Inspect its resource and progress receipts before claiming pressure qualification. Separate OOM, PID exhaustion, peer-loss and containment results from ordinary backend correctness results. The recorded version 4 maximum-one run passed all three phases in 281.436 seconds, with verified cleanup and joint retirement. Execution OOM/PID exhaustion left control alive with no control OOM event. These exhaustion results do not qualify maxima two or four.
Shared media control qualification
To exercise controls while native capacity is occupied, build the current worker image and run the dedicated drill:
php scripts/build-application.php --buildx --target worker --tag baander-worker:test
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_OPERATOR_IN_CONTAINER=1 \
BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
BAANDER_TEST_CHECKOUT_IN_IMAGE=1 BAANDER_TEST_USE_IMAGE=0 \
bash scripts/test-worker-media-control-container.sh
The runner selects ordinary maximum two, the combined web/worker fixture and
BAANDER_TEST_MEDIA_CONTROL_OBSERVATION=1. It inherits the operator runner's
900-second deadline, resource preflight and cleanup. Set BAANDER_TEST_ARTIFACTS
to retain evidence at a chosen path; the default is
var/test-runs/media-control-*. Previously recorded pressure, browser and cleanup
evidence covers single-intention pause/seek/reconnect and reattachment with the
earlier encoder output. Rapid native scrubbing also retains the latest queued
target across an in-flight seek, with pause/resume ordering and generation
authority preserved; see
Playback controls and generations.
That interaction contract has focused backend and player-hook regressions; the
recorded single-intention drill does not establish browser pressure coverage of
rapid scrubbing. The launch observer matches the current DASH manifest.mpd
output. A new pressure result still requires the complete live drill.
Observation mode builds a disposable fixture derived from the production image.
Its test-only FFmpeg wrapper records each video encoder launch before executing
the original native binary with the same PID, start ticks, arguments and streams.
This is an instrumented runtime test; the immutable production-image contract
remains a separate gate. Observation defaults to zero and is incompatible with
BAANDER_TEST_USE_IMAGE=1, which forbids rebuilding the worker fixture.
The drill fills three video slots and one audio slot, queues ordinary work and applies execution CPU pressure for at least 60 seconds. The audio encoder is held after initial output to keep its slot occupied; this does not measure sustained audio throughput. Controls exercise running and paused attachment, ticketed socket reconnect and replay, stale generation refusal, paused seek, active and pending cancellation, and deliberate admission after cancellation. Retired init and media requests must fail even while their old files remain present. Subtitle authority and readiness-wait races need their separate deterministic coverage.
qualification.json records control acknowledgement and heartbeat p99 and maximum,
seek readiness, cancellation retirement, occupancy, sampling gaps and ordinary
progress. Both p99 limits are one second and both maximum limits are two seconds;
seek and cancel request handling also have two-second limits. Seek readiness and
exact encoder retirement after cancellation each have a 30-second watchdog.
Seek readiness ends when both audio and video init/fragment bytes are available,
recorded with a monotonic clock before certification. HTTP retrieval uses bounded
concurrent batches: two playlists, then four init/fragment resources. The fixture
fetches both tracks before running ffprobe, decoding, signature-tampering and
subtitle checks.
All those checks remain mandatory: late bytes, corrupt media or accepted tampering
fail the qualification. Certification time does not extend the availability limit.
media.json holds the scenario receipts; native-launches.jsonl records each job,
generation, output directory, process identity and live predecessors at launch.
Process identity includes PID, start ticks, PID namespace and hostname. The ledger
checks duplicate attempts and overlapping predecessors, and attachment compares the
launch records before and after each concurrent attachment batch. Periodic process
counts alone cannot prove those properties. Keep the container inspections, native verification,
control/execution samples, backlog observations, operator log and cleanup receipts
with these results, including failed runs.
Run browser controls against the immutable production worker image through the combined deployment runner:
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
BAANDER_TEST_BROWSER_CLIENT_CONTROLS=1 \
BAANDER_TEST_TWO_COMMAND_CHECKPOINT="$PWD/scripts/test-two-command-browser-checkpoint.sh" \
bash scripts/test-two-command-deployment-container.sh
BAANDER_TEST_BROWSER_CLIENT_CONTROLS accepts 0 or 1 and defaults to 0.
Enabling it selects client-controls independently of native launch observation.
It requires BAANDER_TEST_TWO_COMMAND=1 and an executable browser checkpoint;
the deployment wrapper supplies two-command mode. Invalid values or missing
prerequisites fail before services start. The production runner selects
BAANDER_TEST_USE_IMAGE=1, preserving the immutable worker-image contract.
Forgejo enables client controls in its production browser job; the configuration
alone does not establish a passing run.
The browser pauses playback and commits three positions through the movie player's full-source slider while holding the first real seek response below the five-second client timeout. It requires exactly the first and latest seek commands, with the latest using the generation accepted by its predecessor, and checks the final source cursor, paused state and unchanged job/session ownership. Slider targets can extend beyond the published seekable window.
The paused-remount check captures its cursor only after decoded, seekable media
is paused, no longer seeking and attached to the current signed rendition set.
It reloads the movie page, reopens the player and requires the same session, job,
generation and source cursor. Resuming must advance both decoded frames and the
playback clock.
The run also retains offline refresh recovery, web restart, worker recovery,
subtitle and retired-resource checks. Individual playback waits have a
45-second watchdog; client-controls has a 360-second checkpoint request budget.
Inspect playback-timeline.json and the browser's result.json or failure.json
for committed slider positions, snapshots, control timings, network failures and
media events. These client checks remain separate from the saturated-control
drill; a production browser result does not qualify that pressure fixture.
Hardware acceleration, cache-sweep policy and measured FLAC DSP CPU cost remain
separate qualification work. Automatic movie quality switching uses the separate
gate below.
Selected video endpoint playback
Run the dedicated production-browser check against the production worker image built from the current checkout:
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
bash scripts/test-movie-endpoint-browser.sh
The wrapper selects BAANDER_TEST_MOVIE_ENDPOINT=1 in the disposable two-command
stack, with a 300-second outer bound. Its phases are authenticated,
movie-endpoint and endpoint-complete; the existing full lifecycle drill remains
separate. A real 42-second video with 48-second audio must advertise duration 42
through the signing response and actual movie slider. The observer reads those
values from the application rather than supplying fixture-known playback
metadata. It uses the real near-end/exact-end slider controls, natural completion
and source-zero replay with advancing decoded frames. The paired prior-resolver
run must retain its actual 48-second failure.
During the bounded natural-end tail, capability renewal and strict rendition-set expansion may replace attachments within the same job, session and generation. Each attachment must supply its genuine metadata/frame baseline before later advancing frames and natural completion are compared. Source-clock continuity and existing timing tolerances remain required; an attachment replacement alone is not a playback defect. This is endpoint/completion/replay evidence, not the full outage drill or uninterrupted playback of all 42 source seconds.
Keep signed delivery, real publication and verified worker/container cleanup receipts. This check qualifies the calibrated metadata and source-clock paths; missing/stale timing, arbitrary muxer tags, additional origins and untested browser/format combinations remain outside exact-end acceptance. The separate 40 ms capture mismatch and bounded diagnostic follow-up remain unresolved.
Automatic movie quality switching
Run the separate Chromium gate against the CI image after installing the pinned web dependencies and Playwright image:
BAANDER_TEST_IMAGE=baander-ci:local bash scripts/test-transcode-adaptive-browser.sh
The default BAANDER_TEST_AUDIO_PROFILE=streaming_stereo uses AAC. Select the
accepted Opus profile explicitly:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_AUDIO_PROFILE=opus_stereo \
bash scripts/test-transcode-adaptive-browser.sh
Other profile names fail before container creation. CI runs AAC and Opus sequentially with separate evidence directories. Readiness records the requested profile; browser receipts also retain the observed native codec. Both the signed HLS master declaration and the published audio must agree with that selection. The same adaptation, signal, throughput, identity and transport checks apply to both profiles.
The fixture generates a moving 144-second, 24-fps source with audio and independently encodes 360p and 720p through the production filters and segment command builder. Both presentations grow during playback; the high producer starts six seconds later so its published edge lags. Production presentation reading, signed media delivery and manifest generation serve the actual playback adapter. In-memory repository and ownership doubles supply fixture state without application database or Redis access.
The browser changes response-body throughput from 20 to 1.8 and back to 20 Mbit/s without setting an HLS level or changing the adapter's estimator or buffering configuration. The three phases have individual bounds of 65, 65 and 90 seconds within a 180-second browser deadline. Producers have 174-second bounds within the fixture supervisor's 240-second deadline. Fixture telemetry uses a separate Node loopback connection, outside the browser's constrained transport.
Each phase requires a fresh signed video request followed by recent native presentation at its rendition, increasing decoded frames and source time, and a positive local audio signal. Ordered 720p-to-360p-to-720p presentation must use one attachment and canonical audio while both producers remain active and publication edges advance. Completed signed video bodies of at least 128 KiB and 0.5 seconds, transferred entirely within the constrained phase, must remain within 1.3 times its configured rate; at least one must reach 0.25 times that rate. These bounds account for transport-event batching and concurrent audio.
Receipts retain body-transfer observations, requests, publication edges, media
events, buffering periods, screenshots and traces. The gate reports rebuffering;
it does not require stall-free playback. Download cancellation during an
intentional adaptation is evaluated separately from HTTP or other request failure.
Set BAANDER_TEST_ARTIFACTS to select the evidence directory. The runner retains
failure evidence and verifies removal of its containers.
This gate qualifies Chromium adaptation through native encoding, production signed delivery and the playback adapter. It does not requalify the whole application lifecycle, other browsers, every network profile, DASH switching, hardware, HDR, alternate audio or formal CMAF conformance. Existing application databases, Redis and original media are outside the fixture.
Sparse movie playback
Use the same runner and dependencies with an explicit sparse scenario and AAC:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_VIDEO_SCENARIO=sparse-fresh \
BAANDER_TEST_AUDIO_PROFILE=streaming_stereo bash scripts/test-transcode-adaptive-browser.sh
Set BAANDER_TEST_VIDEO_SCENARIO=sparse-seek to restart at source second six.
The default scenario remains adaptive; unknown names and sparse scenarios with
Opus fail before container creation. CI runs both sparse scenarios separately.
The fixture generates a thirty-second 0.1-fps movie with three held colour frames and AAC audio. The production probe, encoder, signed delivery and HLS adapter must preserve red until source second ten, green until twenty and blue through the natural end. The seek case retains red through local second four and green through fourteen, then blue to its 24-second end. Native displayed-frame colour samples must coincide with recent audio signal while the clock advances. Cropped native frame colours are checked against both presented-frame time and the callback-entry audio-backed playback clock, with a 50 ms transition tolerance. Fresh signed resources, one attachment, no media errors or unexplained request failures and stable seven-second media targets are required. Each scenario has a 120-second browser deadline and retains growing and completed playlists, samples, screenshots, versions and cleanup receipts.
The observer copies a centered two-by-two RGBA crop from each owned VideoFrame,
checking its native timestamp against callback presentation time. Completed
samples are published in callback order, with at most four copies outstanding;
overflow and capture errors fail the gate. Natural-end evidence cancels callbacks
and bounds copy draining to two seconds. A drain timeout is fatal, with cleanup
still recorded. Each frame must close exactly once; at most 64 error records are
retained.
A failed capture retains detached plain-data diagnostics before the unchanged one-microsecond timestamp assertion. The record includes callback metadata and sequence, entry/post-constructor performance and playback clocks, native timestamp in microseconds and seconds, duration, format, dimensions and visible rectangle. Constructor failures retain available callback/entry data; crop or copy failures also retain constructed-frame fields and available copy-stage clocks. Unavailable values are explicit nulls, and no live frame is retained after closure. Stage intervals include diagnostic work, so they are not pure constructor costs or evidence of timing-neutral observation.
The shared frame-observer.mjs installer runs in both the browser and controlled
Node tests. CI runs its focused lifecycle tests alongside replacement-classifier
coverage, and the runner copies the same observer into the disposable browser
workspace. Exercise its mismatch, constructor/copy failure, ordered publication
and stop/drain behavior with:
node --test ui/web/tests/e2e/transcode-adaptive/frame-observer.test.mjs
Observer reliability remains bounded by retained receipts. The historical native snapshot of 24.64 seconds versus a 24.60-second callback lacked decisive failing timing metadata, so a capture race remains unproven. New diagnostics improve future failure evidence; controlled values and passing browser runs do not retroactively establish that cause, fix the mismatch or qualify universal capture stability. No timestamp tolerance, browser flag or capture retry is changed.
The fixed diagnostic series at 54e3711c recorded exactly two
default-audio-fresh attempts and one sparse-seek control. All passed and
closed all owned frames without capture errors; the historical mismatch did not
recur. Real-run failure retention was not exercised; only controlled mismatches
entered that branch. This is bounded negative evidence, with no extra attempts
or inference about its cause. Exact timings and counts remain in
var/test-runs/frame-capture-diagnostics/acceptance-summary.json.
Only explicitly canceled video media requests with net::ERR_ABORTED may wait
for classification at natural completion. Server receipts and transfer timing
must establish a completed, uncached, signed replacement from a different known
rendition in the same authenticated set and generation, covering the same native
source interval. Unmatched, ambiguous, stale, cached, wrong-interval or unfinished
replacements fail; other request failures fail immediately. Full playback and
content assertions remain required. The retained strict default-audio rerun had
no deferred cancellations; its positive classification branch is covered by Node
tests, not an exercised abort in that successful browser run. Run the pure
replacement-classification tests with:
node --test ui/web/tests/e2e/transcode-adaptive/video-cancellation.test.mjs
These checks qualify the selected sparse software source and Chromium HLS path. They do not certify arbitrary variable-rate input, every source container, hardware, other browsers, DASH browser playback, formal CMAF conformance or the whole application lifecycle.
Delayed Matroska playback
Select the verified Matroska-origin scenario with AAC:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_VIDEO_SCENARIO=matroska-delayed-fresh \
BAANDER_TEST_AUDIO_PROFILE=streaming_stereo bash scripts/test-transcode-adaptive-browser.sh
Set BAANDER_TEST_VIDEO_SCENARIO=matroska-delayed-seek for the six-second restart.
Both require streaming_stereo and retain the sparse runner's bounds, signed
uncached resources, stable targets, one attachment and natural completion checks.
CI runs each scenario with separate evidence.
The source has a verified four-second video origin and audio delayed eight seconds relative to it. Source-relative duration must remain thirty seconds, or 24 after seeking. Near-silent samples precede audio onset at local second eight or two respectively; positive audio must follow and remain present during later colour holds. Signed embedded subtitles must reach the real text track: fresh cues run at seconds two to four and twelve to fourteen; the seek removes the first and places the later cue at six to eight. Tests check cue activation and clearance as well as the served files. A 100 ms observer samples actual native active cues, cue identity, track mode and times; raw cue-change events remain diagnostic evidence. A separate seek after natural completion checks backward re-entry with the same cue objects and unchanged selection and count.
The HLS attachment refreshes native activity if a showing subtitle or caption cue remains active more than 100 ms after its end while the video is not seeking. It removes and re-adds the same cue object without changing cue times or mode; disposal and fatal failure remove the attachment's timing listener. Native HLS fallback keeps its existing behavior. These cases qualify the verified packet-origin contract, not arbitrary Matroska origins or missing prefix evidence.
Ordinary delayed movie audio
Use the same isolated runner with the ordinary delayed source and AAC:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_VIDEO_SCENARIO=ordinary-delayed-fresh \
BAANDER_TEST_AUDIO_PROFILE=streaming_stereo bash scripts/test-transcode-adaptive-browser.sh
Set BAANDER_TEST_VIDEO_SCENARIO=ordinary-delayed-seek for the six-second restart.
Both scenarios require streaming_stereo. The source is a thirty-second 25-fps
MOV with video starting at zero, audio at source second eight, no verified
origin and no embedded subtitles. The fixture verifies that absence explicitly;
Matroska scenarios keep their signed subtitle assertions.
Fresh and restarted playback must retain the red/green/blue source intervals and natural thirty-/24-second tails. Near-silent samples must precede local audio onset at eight/two seconds, followed by positive signal through the later holds. Growing publication, signed uncached resources, canonical audio, stable targets, one attachment and no media errors or unexplained request failures remain required. These scenarios qualify the selected software movie path and Chromium HLS. They do not certify arbitrary timestamp discontinuities, new source-origin handling, hardware, other browsers, DASH browser playback or formal CMAF conformance.
Source default audio playback
Use the isolated runner with the marked-default source and AAC:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_VIDEO_SCENARIO=default-audio-fresh \
BAANDER_TEST_AUDIO_PROFILE=streaming_stereo bash scripts/test-transcode-adaptive-browser.sh
Set BAANDER_TEST_VIDEO_SCENARIO=default-audio-seek for the six-second restart.
Both scenarios use the thirty-second 25-fps colour source without subtitles.
Commentary starts at zero; default dialogue starts at source second eight.
Preflight checks dispositions, the audio ordinal versus global index, selected
attributes and the dialogue's actual first packet. The selected language must be
Danish, and measured browser frequency must identify 880 Hz dialogue rather than
220 Hz commentary at several post-onset and late-hold windows. The tolerance is
1.5 analyser bins, using the recorded sample rate and FFT resolution.
Complete thirty-/24-second playback, eight-/two-second initial silence, frame/colour clocks, growing publication, signed fresh canonical audio, stable targets, one attachment and bounded capture cleanup remain required. The browser negative control restores only the old producer's ordinal-zero map and first-track filter chain in a disposable copy, preserving current preflight, metadata and assertions. It tests the oracle against the demonstrated producer behavior; it does not run an entire old application stack. This selection adds no interactive audio controls or new source-origin qualification.
Movie playback after source audio ends
Use the isolated runner with AAC and the short-audio source:
BAANDER_TEST_IMAGE=baander-ci:local BAANDER_TEST_VIDEO_SCENARIO=short-audio-fresh \
BAANDER_TEST_AUDIO_PROFILE=streaming_stereo bash scripts/test-transcode-adaptive-browser.sh
Set BAANDER_TEST_VIDEO_SCENARIO=short-audio-seek for the six-second restart,
where no source audio remains. The thirty-second 25-fps source retains its full
colour intervals and thirty-/24-second natural tails. Fresh playback must contain
the tone before the source audio ends and measured silence afterward; sought
playback must remain silent throughout. Fixture metadata records selected audio
start/end independently of video duration. Existing audible-per-hold checks remain
in force for other scenarios.
Native and worker checks establish encoded audio tail and cursor coverage; browser checks establish complete playback and measured silence. The browser gate retains growing publication, signed fresh resources, stable targets, one attachment and bounded frame-capture cleanup. Native readiness and coverage requirements remain unchanged. These cases qualify the selected software movie path and Chromium HLS, not arbitrary discontinuities, additional source origins, hardware, other browsers, DASH browser playback or formal CMAF conformance.
Ordinary worker autoscaling qualification
Build the current worker image as above. The bounded comparison runner creates
separate disposable pairs for resident
ordinary maxima 1, 2 and 4, using the operator runner's dependency, lifecycle and
cleanup ownership. Each profile derives a disposable fixture from the selected
production image so it can inject scenario services. BAANDER_TEST_USE_IMAGE=1
is rejected before provisioning. Fixture development classmaps expose workload
classes without installing development dependencies or PHPUnit:
BAANDER_TEST_IMAGE=baander-worker:test BAANDER_TEST_OPERATOR_IN_CONTAINER=1 \
BAANDER_TEST_DOCKER_SOCKET_PATH=/var/run/docker.sock \
BAANDER_TEST_CHECKOUT_IN_IMAGE=1 BAANDER_TEST_USE_IMAGE=0 \
bash scripts/test-worker-autoscaling-container.sh
Each profile has a 900-second bound, including setup and workflow completion. Its
observation callback has a separate 480-second deadline and a 470-second
evidence-wait budget. Individual
Docker observations have 12-second bounds. The runner also enforces a 2000-second
deadline for the complete three-profile comparison. The shared workerCapacity
default is 900 seconds; cleanup retains its separate 120-second budget. Capacity,
media and autoscaling callbacks retain their 240-, 300- and 480-second bounds,
respectively. A profile whose outer deadline expires fails even if its measurements
and cleanup later report success. Image acquisition/build time depends on
the selected runner mode. Retain the printed artifact directory after a failure;
a skipped profile or scenario is an acceptance gap.
The fixture writes thresholds before dispatch, then runs first and repeated bursts of 48 synthetic ordinary messages with two-second I/O waits. Each burst must finish within 150 seconds and return to target one within 110 seconds. Maxima 2 and 4 must reach their admitted maximum and improve burst throughput by at least 1.15 and 1.4 times the one-slot baseline respectively. The pressure phase combines three video jobs and one audio rendition with execution CPU pressure. The audio encoder is deliberately stopped after initial output to retain its occupied slot, while the three video encoders remain active; this does not measure uninterrupted audio throughput during pressure. Desired-state controls must finish within three seconds and pressure must block ordinary growth. Sampling gaps must stay within five seconds, deployment authority must remain continuous, and both units must remain running at the final inspection.
Artifacts include declared thresholds, daemon/container details, manifest
fingerprints, startup timing, timestamped slot/queue/resource/status observations,
completion receipts and media results. Every resident consumer remains fully
reserved even when parked: execution is 4480 + 320 * (max - 1) MiB and
256 + max - 1 PID slots for this fixture, including its 320 MiB scheduled console
reservation. Control remains 2944 MiB/129 PIDs. The four-slot pair therefore needs
8384 MiB; with dependencies, web and headroom the declared total is 15808 MiB,
requiring at least 16 GiB of host memory. Use four available CPUs with the recorded
explicit placement. These are admission envelopes, not measured RSS or throughput.
Local qualification used an AMD Ryzen 7 5800X3D host with eight cores, sixteen threads and 64 GiB of physical RAM (Docker reported 67337879552 bytes), Docker 29.7.2-ce and kernel 7.2.10-1-default. Control used CPU 0 with a one-CPU quota; execution used CPUs 1–2 with a two-CPU quota; dependencies and web shared CPU 3. The operator example recommends maximum two for this measured envelope. The internal constructor and unmodified fixture profile retain maximum one, and production manifests require every field explicitly.
| Resident maximum | First/repeat I/O burst (seconds) | Speedup over maximum one | Execution peak cgroup memory (MiB) | Execution peak PIDs | Peak database connections | Checkpoint (seconds) |
|---|---|---|---|---|---|---|
| 1 | 97.42 / 97.43 | 1.00× | 878.5 | 31 | 35 | 358 |
| 2 | 56.99 / 56.97 | 1.71× / 1.71× | 973.6 | 32 | 37 | 336 |
| 4 | 39.21 / 38.48 | 2.48× / 2.53× | 1021.4 | 34 | 39 | 422 |
Maximum four is a measured option for I/O-heavy work within this envelope, with 5440 MiB/259 PID slots for execution including scheduled console. Its ordinary CPU workload took 27.87 seconds, compared with 26.50 seconds at maximum two; its CPU target reached only two before pressure returned it to one. The example therefore retains maximum two as the general starting profile, with fewer resident processes and connections.
Maximum two grew to two for each burst and returned to one between bursts. During ordinary CPU work it grew to two, then resource pressure lowered the target to one. Its execution cgroup recorded 35.22 CPU seconds for that phase. Media-control p99/max were 0.198/0.250 seconds and heartbeat p99/max were 0.0103/0.0163 seconds. All five scheduled occurrences returned; ordinary pressure work acknowledged 1369 deliveries. A paused-controller probe left one message unread while the controller was stale and completed it after recovery, which took 2.83 seconds.
The first execution checkpoint arrived 17.38 seconds after container start for
maximum two, with 379887616 bytes of cgroup memory and seven observed processes.
That interval includes readiness, harness checks and the observation-gap probe;
it excludes dependency/media preparation. Process counts and summed resident RSS
include the qualification observer and shared pages; they are not idle application
RSS. The first burst is labeled cold in artifacts but follows initialized
duration history. Blocking waits model I/O rather than a provider latency
distribution. Retry races are covered separately through real Messenger tests.
Full harness startup to first checkpoint took 77.25, 106.28 and 66.33 seconds for maxima one, two and four, with one-second precision in the harness start clock. These intervals include fixture-image preparation, dependencies/media, web and pair readiness and the gap probe; they do not isolate production startup latency. PostgreSQL, Redis and web each had a 0.5-CPU quota on CPU 3, with configured memory/PID ceilings of 4096 MiB/64, 256 MiB/64 and 2048 MiB/128 respectively. Together with the pair, the five containers' memory ceilings sum to 13.5, 13.8125 and 14.4375 GiB. These are configured limits, not measured dependency utilization; operator helper overhead is outside those sums.
Artifacts are var/test-runs/autoscaling-qualified-v3/{1,2,4}/qualification.json,
startup-accounting.json in each profile directory and comparison.json in the
parent. The one-slot baseline was retained from the valid earlier run and accepted
by the current stricter verifier; maxima two and four are fresh runs. All three
profiles passed their checkpoints, comparison thresholds and cleanup. Earlier
failed attempts remain failed evidence. The fresh production-image contract,
maximum-two production media drill, real worker-command lifecycle and all twenty
backend isolation checks pass. The version 4 reserved-control CPU/memory/PID drill
also passes at maximum one; its exhaustion results do not qualify maxima two or
four. Final independent review is complete with no remaining blockers for the
recorded hardware, placement and workload. Smaller-host, live-provider, regional
and longer failure/load qualification remain separate. This ordinary gate does
not replace backend, native, retry, media, lease-loss or joint-retirement gates.
The messaging runner waits for PostgreSQL TCP readiness (not its temporary
initialization socket) and prints service logs on readiness timeout. It also
explicitly selects phpunit.xml.dist and provisions
disposable PostgreSQL and Redis for its selected transport, outbox, access-token
cache transaction, deployment lease, and PGroonga compatibility tests. The lease
tests use the actual migration and independent PostgreSQL connections to exercise
ownership, expiry, lock contention, stale epochs and uncertain commit recovery.
The runner includes bin/worker-lease-agent.php and exercises fresh helper processes
against PostgreSQL, including blocked calls, parent deadlines, cancellation,
malformed responses and output limits. Unit tests separately exercise monotonic
lease authority and the runtime coordinator with controlled helper responses;
they verify that pending renewal cannot block worker shutdown at lease expiry.
WorkerLeaseAgentTest also keeps a parent transaction open while the real
fresh-exec helper writes through a distinct PostgreSQL backend. An independent
observer cannot see the parent's uncommitted writes; the parent's backend and
transaction remain usable, and rollback leaves the helper's committed lease intact.
NativeExecutionDaemonTest captures a live FFmpeg process identity and its owned
process group before daemon TERM, then requires daemon exit, no live group member
and removal of the native socket within the fixture deadline.
To run these checks with the real transport response-loss cases:
bash scripts/test-backend-container.sh \
tests/Integration/WorkerLeaseAgentTest.php \
tests/Integration/NativeExecutionDaemonTest.php \
tests/Integration/MessengerAdmissionLifecycleTest.php
BAANDER_TEST_DISABLE_SWOOLE=1 bash scripts/test-backend-container.sh \
tests/Integration/WorkerLeaseAgentTest.php \
tests/Integration/MessengerAdmissionLifecycleTest.php
The response-loss proxy forwards the selected Redis command and captures its
successful server response before dropping only that response. Accepted
XREADGROUP must leave the exact stream ID owned by the named consumer in the
pending-entry list, without a handler effect. Accepted XACK must remove that
pending entry after one handler effect. In both cases, the admission ledger keeps
the same request uncertain and occupied, faults admission and refuses another
receive. An ordinary disconnect without a confirmed accepted command does not
satisfy this test. Existing retry-publication checks remain in the same file.
The separate schema test above uses the fully migrated application kernel to check
that ORM introspection preserves the DBAL-owned lease table. The search tests exercise
production Doctrine filtering and scored SQL against mapped PGroonga indexes;
they require the project's extension-capable database image. Both runners fail on
PHPUnit notices and skipped tests. Neither reads an
ignored local phpunit.xml or runs the whole functional suite. The Makefile's
phpunit, paratest, and ci test steps use the same coordinator and tracked
configuration. PHPUNIT_OPTIONS and PARATEST_OPTIONS forward test selections;
use BAANDER_TEST_PROCESSES to choose the worker count. Direct make exec commands
select this worktree's preview and require a separately configured development
test environment. Prefer the disposable coordinator for database-backed tests;
these commands do not provision a test database or select the persistent instance:
# Run all tests
make phpunit
# Run a single test file
make exec cmd="./vendor/bin/phpunit -c phpunit.xml.dist tests/Unit/Catalog/Domain/Model/AlbumTest.php"
# Run a specific suite
make exec cmd="./vendor/bin/phpunit -c phpunit.xml.dist --testsuite Unit"
# Run the custom PHPStan rule tests
make exec cmd="./vendor/bin/phpunit -c phpunit.xml.dist --testsuite StaticAnalysisRules"
# Run a specific controller's functional tests
make exec cmd="./vendor/bin/phpunit -c phpunit.xml.dist --filter NotificationControllerTest"
# Run a single test method
make exec cmd="./vendor/bin/phpunit -c phpunit.xml.dist --filter testCreateAlbum"
# Run with Xdebug off (faster)
make exec cmd="XDEBUG_MODE=off ./vendor/bin/phpunit -c phpunit.xml.dist"
The worker containment runner creates dedicated, disposable containers with one CPU, 256 MiB of memory, no networking, no added capabilities, and no host mounts. It runs the supervisor core as PID 1 with a child and a TERM-ignoring descendant. Both graceful TERM and supervisor SIGKILL must stop the container and descendant activity within the test deadline. The fixture distinguishes direct-child drain from verified containment: it exits as PID 1 without acknowledging containment or releasing a deployment lease, then the external runner checks namespace shutdown. The runner fails on unexpected exit status or OOM and removes its containers on exit. It tests the proposed container boundary; it does not certify per-child descendant cleanup on restart, host systemd behavior, deployment lease takeover, or measured production resource defaults.
The retirement runner additionally needs host PHP and Docker CLI access to a local
Unix daemon endpoint. It starts a restricted restart-always deployment fixture,
rejects an incorrect boot identity, then exercises the real external retirement
adapter. Confirmed force-removal must prevent restart of the old full container ID;
a subsequent absent-container request must fail closed. PostgreSQL controller tests
in the messaging runner separately verify that retirement failures preserve the
lease and concurrent recovery cannot release a newer epoch. The recovery runner combines the real Docker adapter with PostgreSQL lease
coordination. It needs host PHP with pdo_pgsql, publishes only an ephemeral
loopback database port, and destroys its disposable resources. It verifies
wrong-owner and wrong-label rejection, expiry without takeover, confirmed removal,
new epoch acquisition and stale-token rejection. The fixture exercises external
controller startup and recovery, not a complete LeasedWorkerRuntime deployment.
The predecessor is created with restart disabled and remains unstarted until its
inventory binding and one-shot start claim commit. The fixture checks visibility
through an independent PostgreSQL connection at the real Docker start call, then
verifies that a repeated controller start cannot restart the predecessor. A second
fixture makes Docker create a real container and then discards its acknowledgment.
It verifies durable creation admission, refusal to recreate, exact-recipe
reconciliation without process activity, and registered startup/recovery.
The worker-command runner boots the actual app:worker as PID 1 against disposable
production-mode PostgreSQL and Redis after fresh and repeat migrations.
It also supplies Redis consumer overrides through path and query forms and a
conflicting scheduler path, requiring rejection before locks, lease authority or
child launch, with credential values absent from output. Accepted startup checks
both catalog receivers in separate execution consumers and their generated
identities. It verifies
the control and execution roles, peer/native admission before business launch,
their exact committed deployment tuple (overriding a bogus
inherited epoch), and the namespace/boot-qualified Redis consumer. It kills the
consumer and scheduler separately to require whole-container draining, tests TERM and forced database lease
expiry, and confirms a duplicate
lease claim exits with zero launch attempts. It checks that ownership remains
reserved after shutdown. It also seeds a real registration event before startup,
requires the supervised relay to commit the notification, receipt and channel
handoffs, requires Redis acknowledgment with no pending, delayed or failed work,
and repeats the checks after clearing the event acknowledgment. Outbound
preferences are disabled, the disposable user is unverified and no webhook
endpoints exist; no external delivery is requested. A paused manual console request
runs the registered cache sweep in dry-run mode through the dedicated scheduler
stream; duplicate delivery must not invoke it twice. These checks do not establish
complete application readiness, autoscaling, measured production capacity or
end-to-end media playback and recovery.
CI runs this as a blocking step using BAANDER_TEST_CHECKOUT_IN_IMAGE=1, which
copies source and Composer dependencies from the built application image.
The functional Console/ServeCommandTest verifies that app:serve and the legacy
server name resolve to the same real command and preserve its options.
The functional runner creates an isolated PostgreSQL/Redis network, extracts the
checkout into a fresh directory, and runs Doctrine migrations twice before
executing the requested PHPUnit paths or options. With no arguments it selects tests/Functional.
A fresh database executes Version20261017000000 and the forward migrations
through Version20261018130000; the second run checks that recorded migrations
leave no pending work. The paired-deployment migration must also reject unresolved
or inconsistent singleton history and preserve confirmed retirement records.
Integration tests and runtime fixtures use
tests/Support/FinalSchemaBootstrap.php to create the same final schema by applying
the current migration files in timestamp order. Isolated-schema fixtures require
their own schema to be empty and reuse extensions already provisioned in the
database.
Playlist persistence conflicts can be checked with:
bash scripts/test-functional-container.sh tests/Integration/PlaylistConcurrencyTest.php tests/Integration/PlaylistMutationFirewallTest.php tests/Integration/InitialSchemaMigrationTest.php
The concurrency selection uses independent PostgreSQL sessions to check competing playlist writes, collaborator additions, reorders and cascaded membership removal. It verifies that a losing save rolls back without replacing the winner. The firewall selection checks revision validation and stale-reorder responses alongside the existing permission matrix. Migration checks cover fresh initialization, a populated database upgraded with its playlists intact, and a repeat run with no pending work.
Scheduler occurrence persistence can be checked with:
bash scripts/test-functional-container.sh tests/Integration/SchedulerOccurrenceStoreTest.php tests/Integration/SchedulerOccurrenceExecutionStoreTest.php tests/Integration/SchedulerOccurrenceGuardTest.php tests/Integration/SchedulerOccurrenceDispatchStoreTest.php tests/Integration/SchedulerOccurrenceRelayTest.php tests/Integration/WorkerDeploymentLeaseSchemaTest.php
These checks cover immutable snapshots, independent-connection visibility, competing inserts, rollback and a lost commit acknowledgment. The actual migration also enforces command/JSON bounds and minute-aligned instants; Doctrine's schema filter preserves the tables. Execution checks require a committed attempt before the nested effect, verify the configured Kernel services, and force a failure after an effect through actual Redis/Messenger retries. Redelivery must leave the consumed attempt intact without repeating the effect. Missing/future occurrences, lost commit acknowledgments and wrong-owner return markers cannot grant execution. Admission also rejects absent, malformed, expired or replaced deployment authority without consuming an attempt. Tests cover lease-row contention, authority lost during insertion with transaction rollback, and exact historical-owner receipts after expiry or replacement. The initial schema requires the deployment tuple; initializing it does not upgrade or reset an existing local database. Dispatch tests exercise committed reservations, skipped locked rows, token replacement and idempotent acceptance receipts on PostgreSQL. Real Redis tests send through the explicit JSON serializer and reproduce acceptance followed by a lost acknowledgment; recovery republishes the same occurrence identity without granting a second attempt. A recorded acceptance stops repeated publication while consumers are stopped. Unit tests require the rest of a reserved batch to be attempted after a failed send or receipt. The configured Kernel resolves the relay and its explicit async sender. This does not test automatic polling, fencing effects after lease expiry, broker crash durability or native console/media execution. No occurrence relay loop is enabled yet.
Scheduled-job source parameters can be checked against the real ORM and PostgreSQL:
bash scripts/test-functional-container.sh tests/Integration/ScheduledJobParameterPersistenceTest.php
These checks verify the physical JSON column, fresh ORM reads, parameter order and numeric representation before materializing an occurrence, plus the relevant schema comparison. Native JSON preserves the encoded values; it does not solve stale schedule writes or Doctrine's signed-zero-only dirty-checking limitation.
The occurrence console adapter and worker-stop policy can be checked with:
bash scripts/test-functional-container.sh tests/Integration/ScheduledConsoleExecutorTest.php tests/Integration/ScheduledConsoleWorkerStopTest.php tests/Integration/SchedulerOccurrenceGuardTest.php
Real CLI children verify literal argument handling, PHP heap settings, concurrent stdout/stderr draining, output limits, deadlines, ignored TERM followed by KILL, unexpected signals and exact direct-child reaping. The actual Messenger worker must stop before its next queued job after an uncertain result, including nested handler exceptions; ordinary known failures may continue. Kernel checks verify the executor port and stop subscriber registration. These fixtures do not certify business-command side effects, descendant containment, kernel-stalled processes or total deployment memory. The occurrence handler's unit tests also require the paused state and uncertainty to survive persistence failures without issuing a return receipt or granting a second execution.
The functional runner also supplies the isolated PostgreSQL/Redis environment
aliases used by integration tests that need the full migrated schema and kernel.
CoverExtractionPersistenceTest disables DAMA rollback with its supported attribute
and observes commits through a second connection. It injects a real flush failure,
then verifies that the worker's Swoole pool release and Symfony reset allow the
same handler to retry successfully. Its exact expected pool-reset diagnostic is
asserted; unexpected-output checks remain enabled.
These selections use the shared coordinator's 512 MiB test workers, 2 GiB reporting
processes and default 3600-second overall timeout. Each disposable Compose project
supplies its own database and Redis DSNs, overriding the tracked PHPUnit defaults,
and removes its containers, network and volumes after diagnostics are collected.
The test firewall uses the test authenticator; this does not verify production
bearer-token or DPoP authentication. The full-suite qualification checkpoint above records the verified selection.
New migrations use VersionYYYYMMDDHHMMSS and normal timestamp ordering after
Version20261017000000. The initial migration is irreversible. Its empty-target
guard rejects existing user objects while initialization is pending, and the
unknown-history guard rejects records from the former chain. Neither ordinary
confirmation nor --no-interaction bypasses these checks. See the
manual reset procedure
for an existing database; a reset needs explicit owner approval for that target.
Coverage
make phpunit runs the disposable coordinator with coverage and writes
coverage/report.txt and coverage/clover.xml. Per-lane JUnit and serialized
coverage remain under the printed var/test-runs/<run>/ artifact directory.
make paratest runs the same suites without coverage unless report options are
supplied. make ci uses make phpunit for its backend test step.
Unit Test Conventions
- Manual object construction — tests build domain objects directly (e.g.,
Album::create(...)) rather than using factories. Zenstruck Foundry is available but not the default convention. - Test file structure mirrors
src/— a test forsrc/Catalog/Domain/Model/Album.phplives intests/Unit/Catalog/Domain/Model/AlbumTest.php. - No mocks in domain tests — unit tests exercise real domain logic. Mocks are reserved for infrastructure and external dependencies.
- Use
createStub()when configuring return values without interaction expectations; usecreateMock()when the test asserts calls. Strict runs treat PHPUnit notices as failures. - Use
baander.appor its subdomains for test domains and email addresses. Keep HTTP/DNS mocked or explicitly routed to disposable local services; the project domain does not authorize production traffic. Preserve literal IP cases that test network boundaries. Some inherited fixtures still use other domains; do not copy those examples into new tests.
Example: Unit Test (Domain Logic)
Unit tests live in tests/Unit/ and test pure domain logic with no framework or container:
final class AlbumTest extends TestCase
{
public function testCreateAlbum(): void
{
$album = Album::create(
libraryId: Uuid::generate(),
title: 'Abbey Road',
type: 'album',
);
$this->assertNotNull($album->getId());
$this->assertNotNull($album->getPublicId());
$this->assertSame('Abbey Road', $album->getTitle());
}
public function testCreateAlbumWithEmptyTitleThrows(): void
{
$this->expectException(InvalidArgumentException::class);
Album::create(
libraryId: Uuid::generate(),
title: '',
type: 'album',
);
}
}
Example: Unit Test (Application Handler)
Application handler tests mock domain interfaces (repositories, ports) but test real orchestration logic:
final class StartPlaybackHandlerTest extends TestCase
{
public function testStartsPlaybackAndDispatchesEvent(): void
{
$sessionPort = $this->createMock(PartySessionPortInterface::class);
$eventDispatcher = $this->createMock(EventDispatcherInterface::class);
$handler = new StartPlaybackHandler($sessionPort, $eventDispatcher);
$session = /* ... create or reconstitute a PartySession ... */;
$sessionPort->method('findByUuid')->willReturn($session);
$sessionPort->expects($this->once())->method('startPlayback');
$eventDispatcher->expects($this->once())->method('dispatch');
$command = new StartPlaybackCommand(sessionId: $session->getId(), position: 0);
($handler)($command);
}
}
Functional Test Conventions
Functional tests live in tests/Functional/ and extend App\Tests\Functional\TestCase, which provides a KernelBrowser, test user factories, and request helpers. Each test is wrapped in a database transaction by DAMA DoctrineTestBundle, so tests share a single connection and never pollute each other.
Base Class: App\Tests\Functional\TestCase
| Method | Purpose |
|---|---|
createTestUser(?string $email, string $name, string $password) |
Creates a ROLE_USER user with a random email |
createAdminUser() |
Creates a ROLE_ADMIN user |
createSuperAdminUser() |
Creates a ROLE_SUPER_ADMIN user |
authenticatedRequest(string $method, string $uri, User $user, array $content) |
Sends an HTTP request as the given user. Sets X-Baander-Test-User-Id header, which the TestAuthenticator picks up to authenticate without a real JWT. |
anonymousRequest(string $method, string $uri, array $content) |
Sends an unauthenticated HTTP request |
assertJsonResponse(Response $response, int $expectedStatus, ?string $expectedKey) |
Asserts status code + valid JSON. If $expectedKey is set, asserts the key exists in the decoded body. Returns the decoded array. |
Authentication in Tests
Tests do not use real JWT tokens or loginUser(). Instead, the TestAuthenticator (registered in config/packages/test/security.yaml) reads the X-Baander-Test-User-Id header and returns a SecurityUser for the corresponding user ID. This is set automatically by authenticatedRequest().
// Anonymous request — no auth headers
$response = $this->anonymousRequest('GET', '/api/genres/');
$this->assertJsonResponse($response, 401);
// Authenticated as a regular user
$user = $this->createTestUser();
$response = $this->authenticatedRequest('GET', '/api/genres/', $user);
$this->assertJsonResponse($response, 200);
// Authenticated as admin
$admin = $this->createAdminUser();
$response = $this->authenticatedRequest('POST', '/api/genres/', $admin, [
'name' => 'Rock',
'slug' => 'rock',
]);
$this->assertSame(201, $response->getStatusCode());
Response Shape Gotchas
Controllers use two different response shapes — know which one your test expects:
| Method | Shape | When to use |
|---|---|---|
successResponse($data) |
{"data": {...}} |
Most GET, PATCH, PUT responses |
created($data) |
{...} (flat, no wrapper) |
POST 201 responses — the data is the top-level object |
paginatedResponse($p) |
{"data": [...], "meta": {...}} |
List endpoints with pagination |
errorResponse($msg, $status) |
{"error": $msg, "status": $status} |
Error responses |
When asserting on a created() response, decode the body directly instead of passing 'data' as the expected key:
// created() — flat response
$response = $this->authenticatedRequest('POST', '/api/favorites/', $user, [...]);
$this->assertSame(201, $response->getStatusCode());
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame('song', $data['entityType']);
// successResponse() — wrapped in 'data'
$data = $this->assertJsonResponse(
$this->authenticatedRequest('GET', '/api/favorites/', $user),
200,
'data',
);
$this->assertSame('song', $data['data'][0]['entityType']);
PostgreSQL jsonb Float Round-Trip
PostgreSQL jsonb stores numbers without type annotations. A whole-number float like 5.0 survives the PHP → PostgreSQL → PHP round-trip as integer 5, which fails a assertSame(5.0, ...) assertion. Always use non-whole-number floats in test payloads:
// Bad — survives round-trip as int 5
['volume' => 5.0]
// Good — survives round-trip as float 5.5
['volume' => 5.5]
DAMA Transaction Isolation
Functional tests using the configured DAMA extension are wrapped in a transaction that is rolled back on teardown. This means:
- No cleanup needed — inserted rows vanish automatically.
- Single connection — all tests share one DB connection within a test run.
$client->disableReboot()— the base class calls this to prevent the kernel from rebooting between requests, which would break the transaction.
This shared test connection cannot prove what another connection sees after a producer commits or rolls back. Production runtime drills use independent connections and disposable services for that purpose; their fixtures are not DAMA-wrapped functional tests.
Production Runtime Drills
bash scripts/test-container-startup.sh
timeout 300 bash scripts/test-worker-runtime-container.sh
bash scripts/test-worker-command-container.sh
bash scripts/test-outbox-runtime-container.sh
bash scripts/test-producer-runtime-container.sh
bash scripts/test-web-runtime-container.sh
bash scripts/test-web-shutdown-container.sh
These run the production kernel and actual repositories against disposable PostgreSQL and Redis on isolated networks, with cleanup on exit. The startup contract uses isolated executable probes to check argument, PID and signal handling. The legacy worker-runtime drill checks transport redelivery using a historical Supervisor fixture; it does not represent deployment packaging. The worker-command drill exercises the current PID-1 supervisor and committed lease lifecycle. The outbox drill checks notification projections, receipts, channel handoffs, and replay after a lost acknowledgement. The producer drill rejects outbox writes, verifies rollback through a second connection, and retries through the same bus. It covers operator user creation, registration tokens/preferences, email verification token retention, and recovery after an ORM flush closes the entity manager. This is coverage of three auth producers, not every event producer or a whole-schema audit.
The web drill starts the real Swoole server (app:serve) in the production
environment, with Mailpit receiving its mail. It fails unless a password-reset
email for a user whose saved language is Danish arrives in Danish, an in-progress
rendition streams with Accept-Ranges: none and grows as it is written, and the
server's own encode of a generated track answers Range: bytes=100-199 with 206
once complete. It also replays Safari's opening requests (bytes=0-1, then
bytes=0-65535) against an in-progress rendition and compares the answers with
tests/Fixtures/WebRuntime/safari-in-progress.txt. For the in-progress cases the
fixture holds the rendition's encoder lock and writes its partial file itself, so
encode speed cannot change the answers. A different answer fails the drill with a
diff; when the change is intended, update the baseline in the same commit. The
baseline records what the server answers, not whether Safari plays it.
The drill then checks health alerts, with the monitor checking every 2 seconds
(HEALTH_MONITOR_INTERVAL_SECONDS=2). It stops PostgreSQL, reloads the server's
workers during the outage through the swoole bundle's API server
(PATCH http://127.0.0.1:9200/api/server), and starts PostgreSQL again. It fails
unless the admin and the super-admin each receive exactly one PostgreSQL alert,
delivered after PostgreSQL returns and naming an outage that began before the
reload, and no second alert arrives in the next three checks.
The drill then stops Redis while signed-in sessions exist. It signs in as the
admin twice, each time with a fresh DPoP key, through the real login
(app:auth:setup-clients has seeded the first-party client), stops the Redis
container, and drives both sessions with scripts/web-runtime/redis-outage.php
inside the app container. The first pair is the session that survives the
outage; the second pair is logged out during it. The drill fails unless:
- the refresh of that session answers
503with aRetry-Afterdelay,Cache-Control: no-store, noDPoP-Nonceand noWWW-Authenticate, andGET /api/auth/mewith the session's access token answers the same503, not403, each within the web client's 10-second attempt; - a logout with the second pair answers
200during the outage, because logout verifies its proof without the replay check; - the monitor logs Redis unhealthy during the outage;
- after Redis starts again, the same refresh token refreshes, taking one nonce challenge when the restart lost the stored nonce, and the new access token is accepted;
- the second pair's access token and refresh token answer
401, since its chain was revoked during the outage; - the admin and the super-admin each hold exactly one Redis alert, and no second alert arrives in the next three checks.
The web shutdown drill, scripts/test-web-shutdown-container.sh, stops the web
server the way a container runtime does and takes about five to six minutes. It
has two parts:
- A bare Swoole server with the settings of
app:serveruns as PID 1 and records Swoole's shutdown order: a master timer started inonBeforeShutdownkeeps client connections open and served, the connections close when the reactor threads join, and only then do the workers stop. The drain depends on this order, so a Swoole upgrade that changes it fails here. app:serveruns as PID 1 throughdocker/general/start-web.sh, with disposable PostgreSQL and Redis andWEB_DRAIN_SECONDS=10(a drain window of 3 seconds and amax_wait_timeof 1 second). The drill fails unless:docker stop,SIGINT,DELETE /api/serverandswoole:server:stopeach run one drain and exit0within the deadline, andSIGTERMfollowed bySIGINTstill drains once;swoole:server:stopitself exits0;- a worker reload through
PATCH /api/serverkeeps the server and an open WebSocket, and starts no drain; web has no CPU pool to restart; - an open WebSocket receives close code
1012before its connection ends, and an idle server closes the drain window as soon as every worker has reported drained; /readyon a connection kept alive from before the stop answers503withConnection: close, and/liveanswers200with"draining": true;- a short request in flight at the stop completes with its full body, and a
held progressive stream is cut so that curl exits with
18; - a client that stopped reading a stream does not hold the server past the deadline;
- a stopped event worker is killed by Swoole and the server still exits
0, and a stopped manager is killed by the drain watchdog at the deadline, with exit1; - with the server started through
docker execrather than as PID 1, no web process remains after the stop; web starts no FFmpeg or CPU pool children; - with PostgreSQL down, the start-up cleanup logs its failure once and the
server still drains and exits
0.
The drill accepts the same image variables as the other runtime runners and enforces its own 15-minute timeout.
Fixtures under tests/Fixtures/ prepare and observe only those disposable
databases. Do not invoke them against an application database. The runtime runners
accept BAANDER_TEST_IMAGE and BAANDER_TEST_POSTGRES_IMAGE; CI sets
BAANDER_TEST_CHECKOUT_IN_IMAGE=1 to test the checkout already built into its image.
Outbox, producer and web runners also enforce their own 180-second timeout.
WebSocket handshake security
The security runtime runner starts production app:serve with disposable
PostgreSQL, Redis and OAuth keys on an internal network:
BAANDER_TEST_IMAGE=baander-ci-prewarm:local bash scripts/test-security-runtime-container.sh
Its 360-second deadline covers malformed HTTP probes and WebSocket ticket checks. The WebSocket fixture obtains tickets through the real OAuth/DPoP firewall, then uses only a ticket query and Origin for the upgrade, as a browser does. It checks proof replay, Origin/path/query rejection without consumption, single-use and concurrent redemption, expiry, disabled users, logout closure and rejection of a pending ticket after logout. Paused and stopped Redis cases must return bounded 503 responses; fresh issuance and upgrades must work after recovery. The runner checks application logs for the actual ticket and access-token values it used.
The shutdown fixture also obtains a ticket before opening its socket. Both
fixtures use https://baander.app as their configured browser Origin even though
they connect to the disposable server over loopback. Neither fixture bypasses the
Origin check for local addresses.
app.websocket.allowed_origins defaults to the origin of APP_URL. An explicit
empty list denies every upgrade; additional entries must be serialized HTTP or
HTTPS origins and must agree with the API's CORS policy. External TLS proxies must
omit credential queries from access logs and avoid raw-request error logging.
The bundled nginx configurations apply that policy to exact /api/ws requests;
pre-location parser errors and an operator's upstream proxy remain separate limits.
The proxy drill validates all three configurations against a refused upstream and
checks query/referrer redaction without disabling ordinary-route diagnostics:
docker build -f docker/nginx/Dockerfile.test -t baander-websocket-proxy-test:local docker/nginx
bash scripts/test-websocket-proxy-logs.sh
Forgejo runs both security drills as blocking steps.
Safari Playback of an Encoding Rendition (Manual)
The web drill records the server's answers to Safari's opening requests as a
baseline; it does not show whether Safari plays them. While a rendition is still
encoding, the server answers Safari's byte-range probe with 200,
Accept-Ranges: none and the whole body as it grows. Whether Safari plays that
stream is checked by hand, on macOS and on iOS.
Prepare:
- Record the macOS version and its Safari version, and the iOS version and its Safari version.
- Turn
transcode.enabledon and pick a track whose original file Safari cannot play. The player then falls back to a transcoded stream; in Web Inspector's network tab the stream request carriesformat=. - The web client asks for the first of
opus,aacandmp3that Safari'scanPlayType()accepts. Record which format it chose. To check a format it did not choose, start the stream from Web Inspector's console on the Baander tab, for examplea = new Audio('/api/stream/track?id=<publicId>&format=aac'); a.play(); the page's service worker signs that request as it does the player's. - Make sure the rendition is not cached: use a track that has not been streamed
in that format, or remove the track's directory under
CONVERT_STORAGE_PATH/audio-renditions/. The first request then starts the encode and streams it while FFmpeg writes it.
For each format:
- Start playback. Seeking is not expected to work while the rendition encodes.
- Keep it playing until the encode has finished. A new request for the same URL
answers with
Accept-Ranges: bytesonce it has. - Seek forward and back: with the player's position bar, or with
a.currentTime = <seconds>for a stream started from the console. - Record whether playback started, whether it kept playing past the end of the encode, and whether the seek landed.
If, for any recorded Safari version and format, playback does not start, stops early, or cannot seek after the encode has finished, open a follow-up to change the in-progress rendition's answer to Safari's byte-range probe.
Safari Resume After a Server Restart (Manual)
The browser suite checks the player's resume in Chromium and how Firefox and Playwright's WebKit report a cut stream. Neither shows what Safari does, so check it by hand, on macOS and on iOS, against the development stack:
- Record the macOS version and its Safari version, and the iOS version and its Safari version.
- Play a track whose original file Safari plays, and wait until it has played for at least 30 seconds.
- Restart the web container with
docker compose restart app. - Record whether the player showed "Reconnecting…" or "Tap to resume", whether it resumed the same track, and how far the resumed position was from the position at the restart.
- Repeat with a transcoded track: turn
transcode.enabledon and pick a track whose original file Safari cannot play and whose rendition is not cached (see the previous check). Restart while the rendition still encodes. The player should keep showing "Reconnecting…" until the restarted encode is complete, then resume at the position.
A resume that skips the track, starts it from the beginning or never ends the
reconnecting state is a defect in the player's recovery
(ui/web/src/features/player/stores/player-playback-recovery.ts).
Example: Functional Test (Full Pattern)
final class FavoritesControllerTest extends TestCase
{
public function testIndexRequiresAuthentication(): void
{
$response = $this->anonymousRequest('GET', '/api/favorites/');
$this->assertJsonResponse($response, 401);
}
public function testIndexReturnsAddedFavorites(): void
{
$user = $this->createTestUser();
$this->addFavorite($user, 'song', 'song-abc123');
$data = $this->assertJsonResponse(
$this->authenticatedRequest('GET', '/api/favorites/', $user),
200,
);
$this->assertCount(1, $data['data']);
$this->assertSame(1, $data['meta']['total']);
}
public function testAddReturns201(): void
{
$user = $this->createTestUser();
$response = $this->authenticatedRequest('POST', '/api/favorites/', $user, [
'entityType' => 'song',
'entityPublicId' => 'song-create',
]);
$this->assertSame(201, $response->getStatusCode());
$data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
$this->assertSame('song', $data['entityType']);
}
private function addFavorite($user, string $entityType, string $entityPublicId)
{
return $this->authenticatedRequest('POST', '/api/favorites/', $user, [
'entityType' => $entityType,
'entityPublicId' => $entityPublicId,
]);
}
}
Functional Test Coverage
The functional suite covers these controllers (tests live in tests/Functional/Controller/):
| Context | Controller | Tests | Notes |
|---|---|---|---|
| Session | DeviceController | 19 | Device registration + rename |
| Notification | PushSubscriptionController | 15 | Subscribe/unsubscribe/remove-all |
| Notification | NotificationController | 19 | List, markRead, markAllAsRead, delete |
| Notification | WebhookController | 13 | Admin-gated CRUD |
| UserPreference | AccentColorController | 7 | GET + PUT |
| UserPreference | AudioPreferencesController | 14 | Versioned save, history, rollback |
| UserPreference | LayoutPreferencesController | 13 | Strict 2-field payload |
| UserPreference | PlayerPreferencesController | 13 | Strict 9-field payload |
| UserPreference | EqDeviceProfileController | 33 | CRUD + activate, ownership and safe 404 responses |
| UserPreference | SidebarConfigController | 10 | Per-media-type config |
| UserPreference | ThemeMoodController | 7 | GET + PUT |
| Catalog | GenreController | 16 | CRUD, ROLE_ADMIN on write |
| Favorites | FavoritesController | 13 | Add/list/remove, type filter |
| Shared | HealthCheckController | 5 | /health, /ready, /live probes |
Bugs Found by Functional Tests
The functional suite has surfaced production bugs that were fixed:
- Push subscribe
Assert\Choice—Assert\Choice([...])used positional args; Symfony 7 requiresAssert\Choice(choices: [...]). Subscribe was always 500. - Notification
findByPublicId— passed a raw string to aPublicIdTypeDoctrine column, which rejects non-PublicId values. Mark-read and delete were always 500. - Notification
save()— never persisted the domain model'spublicId, so the entity got a divergent listener-generated ID. - Notification
markAllAsRead— used->set('e.isRead', true), which PHP coerced to string"1", causing a PostgreSQL boolean type mismatch. Always 500. - Notification
markReadcontroller — returned the stale domain model (isRead:false) after marking read. - GenreController missing authorization —
update()anddestroy()lacked#[IsGranted('ROLE_ADMIN')]. Any authenticated user could modify or delete genres. - Missing
user_theme_moodsmigration — the ThemeMood entity had no database table.
EQ profile show, update, delete, and activate now enforce ownership in the application port implementation. Foreign, missing, and malformed profile IDs return 404; unrelated administrators have no bypass. Default-profile deletion by its owner still returns 422. The 33 functional tests exercise Symfony's firewall with the test authenticator, not the production OAuth/DPoP handshake. Notification mark-read and delete also have ownership guards and cross-user 403 regression coverage; the earlier ownership warning in this guide was stale.
Design Gaps Pinned by Tests (Not Yet Fixed)
Some tests document known design-level gaps by asserting the current behavior rather than the ideal:
- Dead-code optimistic locking — AudioPreferences, LayoutPreferences, and PlayerPreferences controllers catch
RuntimeExceptionfor version conflicts, butsaveForUser()never throws it. The 409 response path is unreachable. - Favorites missing Choice constraint —
AddFavoriteRequest.entityTypehasNotBlankbut noChoice, so invalid types pass DTO validation and crash inFavoriteType::from()with a 500.
Static Analysis
PHPStan runs alongside tests:
# Run PHPStan
make phpstan
# Check architectural dependencies
make exec cmd="vendor/bin/deptrac analyse --no-cache --no-progress"
Run these in the application PHP runtime with the current Composer dependencies. Existing baselines record debt; do not regenerate or expand them to hide a failing check. Address runtime defects before annotation cleanup. The full application publisher requires both analyses to succeed, in addition to its test gates.
Application publication gates
The application publisher in .forgejo/workflows/ci.yaml requires backend quality,
frontend quality, and DSP qualification. Frontend qualification includes the embedded
player and Chromium authentication, audio graph, and store-debugger suites. Checkout
steps in the application, frontend, and DSP workflows fetch and verify the event SHA,
so a later branch update cannot replace the source under qualification.
PublicationWorkflowTest checks the actual YAML dependency graph, executes gate shell
commands with failing local tools, and exercises checkouts against a local Git remote
whose branch advances. It rejects unavailable or mismatched event commits. These are
local contracts and shell regressions; they do not certify a live Forgejo scheduler.
Standalone CLI and base-image publication policies need their own review.
Frontend Testing
The web frontend uses Vitest:
cd ui/web
yarn test # Run tests
yarn test:watch # Watch mode
yarn test:coverage # With coverage
For application browser tests, start this worktree's isolated preview and supply its URL explicitly:
make preview-up
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless
The runner does not provision an app or fall back to baander.test. Preview source
media is read-only and writable state is isolated; workers use the snapshot from
make preview-up. The local .test preview names are the explicit exception to
the baander.app fixture rule for this application browser workflow. Scripted
browser-auth and DSP suites retain their own disposable servers.
See Frontend Development for more details.
For focused styling checks against that preview, run:
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless \
tests/e2e/styling tests/e2e/settings/preferences.spec.ts tests/e2e/layout/layout.spec.ts
The styling suite signs in through the real login flow. Deterministic catalog GET fixtures and a local silent WAV cover empty and populated views and an active player without library scans or authentication-store injection. Its DOM appearance matrix checks all 32 mood/accent combinations; separate Settings tests exercise saved preferences through the application. Playback and mocked command-palette cases block service workers for deterministic fixture interception; login remains real. Screenshots, geometry, keyboard focus, reduced-motion and computed-contrast assertions cover selected routes, controls, overlays and viewport sizes, not every application state. See the browser-test guide for scope and audio precautions.
The recessed music footer has a focused rendering check:
E2E_BASE_URL="$(make preview-url)" yarn --cwd ui/web e2e:headless \
tests/e2e/styling/footer-material.spec.ts
It checks every mood/accent combination against pixels from the painted surface, including the gradient, rather than treating the background as a flat color. Samples come from the empty right padding at the labels' and controls' vertical positions; artwork, glyph edges and shadow borders are excluded. This sampling assumes the centerpiece's background varies vertically. A future horizontal or localized reflection would require different sampling. The shared flat-background contrast helper does not certify gradients.
The same spec captures desktop and narrow layouts with seek focus visible. Geometry checks cover the compact footer height, vertical alignment and contained controls. A desktop pixel check includes the lower lip when comparing the centerpiece's top and bottom gaps. Review those captures at normal size to judge the subtle recess and lower edge. Browser pixels establish rendered contrast, not the near-black behavior of a physical OLED panel; record hardware observations separately when available.
The Electron app has its own Node test suite, run with Node 26:
cd ui/electron
yarn test # node --import tsx --experimental-test-module-mocks --test tests/*.test.ts
It covers the preload bridges, IPC validation and renderer trust, the
discovery and deep-link services, DSP asset regeneration and the native AAC
codec service (decoding, encoding in every profile including xHE-AAC with
its loudness metadata, and the loudness meter). The codec tests use the
compiled @baander/aac-native binding from node_modules or from a build in
the codec directory, and skip the binding-dependent cases when neither
exists. Without tsx the service test also runs under Node's own type
stripping (node --experimental-strip-types --experimental-test-module-mocks --test tests/aac-service.test.ts); the IPC test does not, since its sources
import modules without extensions.
The codec itself has CTest suites and a binding smoke test:
cd packages/baander-aac
mkdir -p cmake-build && cd cmake-build && cmake .. -DCMAKE_BUILD_TYPE=Release && make && ctest
cd .. && npm install && npm test
The CTest suites check the standard tables, the MDCT, bitstream coding,
decoder behaviour on hand-built and damaged streams, encode/decode roundtrips
at every sample rate and rate-control mode, rate control, the coding tools
(noise substitution, intensity stereo, TNS) in the encoder's streams, and
interoperability with FFmpeg's encoder and decoder when ffmpeg is on
PATH; the interop suite skips otherwise.
The native audio graph suite also runs normalization against the shipped loudness WASM without mounting Equalizer. It checks output attenuation, independent volume and mute, chain rebuilds, disable, and programme reset. Unit coverage checks settings restoration, native-readiness rejection, expiry, and passive playback volume ownership.