cd /home/sebas && [ -d browser-harness ] && echo exists || echo missing && [ -d /mnt/rpi/pi-config ] && echo pi-config-ok


missing
pi-config-ok


curl: (52) Empty reply from server


write

browser-harness

import { defineConfig, devices } from '@playwright/test';

const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: {
    timeout: 5_000,
  },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL,
    headless: true,
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
  outputDir: 'test-results',
});

# browser-harness

Shared machine-local Playwright harness for browser smoke tests on `ballbox-first`.

## Purpose
- keep generic browser automation outside app repos
- verify local browser/runtime basics without mixing app-specific tests
- prefer Chromium headless

## Install
```bash
cd /home/sebas/browser-harness
pnpm install
pnpm install:browsers
```

## Run
```bash
cd /home/sebas/browser-harness
pnpm test:smoke
```

## Base URL
Defaults to `http://127.0.0.1`.
Override with:
```bash
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3010 pnpm test:smoke
```

## Scope
Keep only generic machine/browser smoke tests here.
App-specific flows stay in their own repos.

import { test, expect } from '@playwright/test';

test('localhost responds over HTTP', async ({ request, baseURL }) => {
  const response = await request.get(baseURL ?? 'http://127.0.0.1');
  expect(response.ok()).toBeTruthy();
});

import { test, expect } from '@playwright/test';

test('chromium exposes baseline web platform capabilities', async ({ page, baseURL }) => {
  await page.goto(baseURL ?? 'http://127.0.0.1');

  const capabilities = await page.evaluate(async () => {
    let cacheAvailable = typeof window.caches !== 'undefined';
    let cacheWriteOk = false;
    if (cacheAvailable) {
      const cache = await window.caches.open('browser-harness-smoke');
      await cache.put('/__browser-harness__', new Response('ok', { status: 200 }));
      const match = await cache.match('/__browser-harness__');
      cacheWriteOk = (await match?.text()) === 'ok';
      await window.caches.delete('browser-harness-smoke');
    }

    const blob = new Blob(['hello'], { type: 'text/plain' });
    const objectUrl = URL.createObjectURL(blob);
    const objectUrlOk = objectUrl.startsWith('blob:');
    URL.revokeObjectURL(objectUrl);

    return {
      onlineProperty: typeof navigator.onLine === 'boolean',
      cacheAvailable,
      cacheWriteOk,
      objectUrlOk,
    };
  });

  expect(capabilities.onlineProperty).toBeTruthy();
  expect(capabilities.cacheAvailable).toBeTruthy();
  expect(capabilities.cacheWriteOk).toBeTruthy();
  expect(capabilities.objectUrlOk).toBeTruthy();
});

#!/usr/bin/env bash
set -euo pipefail
cd /home/sebas/browser-harness
exec pnpm "$@"


Successfully wrote 457 bytes to /home/sebas/browser-harness/package.json


Successfully wrote 705 bytes to /home/sebas/browser-harness/playwright.config.ts


Successfully wrote 659 bytes to /home/sebas/browser-harness/README.md


Successfully wrote 232 bytes to /home/sebas/browser-harness/tests/smoke/local-web.spec.ts


Successfully wrote 1264 bytes to /home/sebas/browser-harness/tests/smoke/browser-capabilities.spec.ts


Successfully wrote 84 bytes to /mnt/rpi/pi-config/bin/browser-harness


bash

chmod +x /mnt/rpi/pi-config/bin/browser-harness && cd /home/sebas/browser-harness && pnpm install


Progress: resolved 1, reused 0, downloaded 0, added 0
Progress: resolved 4, reused 0, downloaded 2, added 0
Packages: +3
+++
Progress: resolved 4, reused 0, downloaded 3, added 3, done

devDependencies:
+ @playwright/test 1.59.1

Done in 2.5s using pnpm v10.33.0


# Streetcast Runtime Parity

## Covered
- Manifest polling: covered by `/player/[screenId]` lightweight runtime refresh loop.
- Sequential creative playback: covered by runtime rotation over active manifest creatives.
- Local media cache: covered by screen-scoped browser cache with cached blob playback preference.
- Background preload: covered by manifest-driven media warming after each runtime refresh.
- Offline continuity: covered by last-good-manifest retention plus cached blob playback fallback.
- Impression retry queue: covered by bounded browser queue flushed on interval and `online` events.
- Browser automation parity: covered by Playwright cached-blob/offline checks on `/player/[screenId]`.

## Intentionally Different
- Ballbox runtime uses Ballbox-owned manifest/impression APIs instead of Streetcast endpoints.
- The production TV runtime path is `/player/[screenId]`.
- Playback source of truth is Ballbox Postgres scheduling, not legacy Streetcast ownership data.

## Still Pending
- Real-device verification is still required before calling Streetcast migration complete.
- The missing proof is hardware/browser behavior for CacheStorage plus object-URL playback outside local desktop automation.
- The runtime now ships an on-screen `Export parity snapshot` action that downloads a JSON snapshot (screen id, manifest/playback/cache/network/reporting state, user agent, capture time) to standardize evidence capture.

## Manual Real-Device Check
1. Open `/player/screen_001` on the target TV/browser with Ballbox pointed at the real/shared DB.
2. Wait for the runtime diagnostics card to show:
   - `Playback: cached`
   - `Active source: blob`
   - `Cache API: available`
   - `Cached creatives: 1` or higher
   - `Network: online`
3. Confirm the video starts and loops without a visible reload gap while the diagnostics stay on `blob`.
4. Disconnect network on the device or gateway after the blob source is active.
5. Confirm the runtime keeps playing and the diagnostics switch to:
   - `Network: offline`
   - `Playback: cached`
   - `Active source: blob`
6. Keep the device offline past one manifest refresh interval and verify `Manifest refresh: failed` appears without playback stopping.
7. Reconnect network and confirm:
   - `Network: online`
   - queued impressions eventually return to `0`
   - heartbeat timestamp advances again
8. Capture evidence:
    - photo/video of the runtime while online and offline
    - browser version / device model
    - exact screen id used
    - JSON file exported from `Export parity snapshot` in both online and offline moments
    - any deviation from the expected diagnostics

## Exit Criteria
- One real device shows cached blob playback before and during an offline window.
- Runtime keeps playing through at least one failed manifest refresh while offline.
- Heartbeat and impression reporting recover after reconnect.
- This doc should be updated with the device/browser/date/result once the run happens.


cd /home/sebas/browser-harness && pnpm install:browsers

python3 -m http.server 4011 --bind 127.0.0.1 >/tmp/browser-harness-http.log 2>&1 & echo $!


> browser-harness@0.1.0 install:browsers /home/sebas/browser-harness
> playwright install chromium

Downloading Chrome for Testing 147.0.7727.15 (playwright chromium v1217) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1217/chromium-linux-arm64.zip
|                                                                                |   0% of 183 MiB
|■■■■■■■■                                                                        |  10% of 183 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■                                                        |  30% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                                |  40% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                        |  50% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                |  60% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                        |  70% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                |  80% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■        |  90% of 183 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 183 MiB
Chrome for Testing 147.0.7727.15 (playwright chromium v1217) downloaded to /home/sebas/.cache/ms-playwright/chromium-1217
Downloading FFmpeg (playwright ffmpeg v1011) from https://cdn.playwright.dev/dbazure/download/playwright/builds/ffmpeg/1011/ffmpeg-linux-arm64.zip
|                                                                                |   0% of 1.6 MiB
|■■■■■■■■                                                                        |  10% of 1.6 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■                                                        |  30% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                                |  40% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                        |  50% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                |  60% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                        |  70% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                |  80% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■        |  90% of 1.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 1.6 MiB
FFmpeg (playwright ffmpeg v1011) downloaded to /home/sebas/.cache/ms-playwright/ffmpeg-1011
Downloading Chrome Headless Shell 147.0.7727.15 (playwright chromium-headless-shell v1217) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1217/chromium-headless-shell-linux-arm64.zip
|                                                                                |   0% of 107.5 MiB
|■■■■■■■■                                                                        |  10% of 107.5 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■                                                        |  30% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                                |  40% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                        |  50% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                |  60% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                        |  70% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                |  80% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■        |  90% of 107.5 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 107.5 MiB
Chrome Headless Shell 147.0.7727.15 (playwright chromium-headless-shell v1217) downloaded to /home/sebas/.cache/ms-playwright/chromium_headless_shell-1217


cd /home/sebas/browser-harness && PLAYWRIGHT_BASE_URL=http://127.0.0.1:4011 pnpm test:smoke


> browser-harness@0.1.0 test:smoke /home/sebas/browser-harness
> playwright test tests/smoke

Running 2 tests using 2 workers

  ✓  2 [chromium] › tests/smoke/local-web.spec.ts:3:5 › localhost responds over HTTP (73ms)
  ✓  1 [chromium] › tests/smoke/browser-capabilities.spec.ts:3:5 › chromium exposes baseline web platform capabilities (1.3s)

  2 passed (2.5s)


kill 645907 && cd /mnt/rpi/repos/ballbox && git status --short --branch


## main...origin/main


write

scope: machine
type: artifact
project_id: pi-config
repo_id: pi-config
origin_agent: codex
source_ref: /home/sebas/browser-harness
summary: Shared machine-local Playwright harness installed on ballbox-first.
body: Created /home/sebas/browser-harness as the reusable Chromium-only Playwright harness outside app repos. Installed @playwright/test, downloaded Playwright Chromium browser binaries under /home/sebas/.cache/ms-playwright, added smoke tests for localhost HTTP response and baseline browser capabilities (CacheStorage write/read and object URL creation), and added wrapper script /mnt/rpi/pi-config/bin/browser-harness to run pnpm commands from the harness directory. Verified with PLAYWRIGHT_BASE_URL=http://127.0.0.1:4011 pnpm test:smoke => 2 passed.


Successfully wrote 763 bytes to /tmp/browser-harness-memory.txt


Sí. Ya dejé Playwright base bien montado fuera del repo, con suite propia, sin meter deuda rara.

Hecho:
- creé harness compartido en `/home/sebas/browser-harness`
- Chromium only, headless por default
- instalé `@playwright/test`
- instalé browser binaries de Playwright en `~/.cache/ms-playwright`
- agregué suite propia de smoke tests
- agregué wrapper en PATH:
  - `/mnt/rpi/pi-config/bin/browser-harness`

Tests incluidos:
- `tests/smoke/local-web.spec.ts`
  - verifica que un endpoint HTTP local responda
- `tests/smoke/browser-capabilities.spec.ts`
  - verifica capacidades base del navegador:
    - `CacheStorage`
    - escritura/lectura en cache
    - `object URL`

Verificación hecha:
- levanté un server HTTP temporal local
- corrí:
  - `PLAYWRIGHT_BASE_URL=http://127.0.0.1:4011 pnpm test:smoke`
- resultado:
  - `2 passed`

Archivos:
- `/home/sebas/browser-harness/package.json`
- `/home/sebas/browser-harness/playwright.config.ts`
- `/home/sebas/browser-harness/README.md`
- `/home/sebas/browser-harness/tests/smoke/local-web.spec.ts`
- `/home/sebas/browser-harness/tests/smoke/browser-capabilities.spec.ts`
- `/mnt/rpi/pi-config/bin/browser-harness`

Cómo usarlo:
- `cd /home/sebas/browser-harness && pnpm test:smoke`
- o por wrapper:
  - `browser-harness test:smoke`

Importante:
- en esta máquina no había `playwright-mcp` disponible en PATH
- no me apoyé en eso
- tampoco toqué `ballbox` todavía
- `main` del repo sigue limpio

Siguiente paso lógico:
1. usar este harness como base general ya instalada
2. recién ahí encarar `OPS-2` completo en `ballbox`:
   - correr validación local/e2e
   - preparar evidencia
   - dejar sólo el cierre de dispositivo real si sigue siendo necesario

Antes de seguir con `OPS-2`, falta una sola confirmación porque ya implica modificar el repo `ballbox`:
- ¿arranco ahora con branch nueva desde `origin/main` y ejecuto `OPS-2` completo hasta donde da la máquina?


me olvide de pedir por cuestiones de espacio que eso este en /mnt/rpi


Entendido.

Lo dejé en `/home/sebas/browser-harness`, pero por tu restricción de espacio debería vivir en `/mnt/rpi`.

No seguí con `OPS-2`.

Propongo moverlo a:
- `/mnt/rpi/browser-harness`

Y ajustar:
- wrapper `browser-harness`
- cualquier referencia en README/config si hace falta

Pregunta que cambia alcance:
- ¿lo querés en `/mnt/rpi/browser-harness` o en `/mnt/rpi/repos/browser-harness`?

Mi recomendación:
- `/mnt/rpi/repos/browser-harness`
porque queda versionable y ordenado como herramienta compartida, sin mezclarlo con repos de producto.

Si querés, lo muevo ahora y revalido la suite ahí.


dale


bash

set -euo pipefail
mkdir -p /mnt/rpi/repos
mv /home/sebas/browser-harness /mnt/rpi/repos/browser-harness
python3 - <<'PY'
from pathlib import Path
p = Path('/mnt/rpi/pi-config/bin/browser-harness')
s = p.read_text()
s = s.replace('/home/sebas/browser-harness', '/mnt/rpi/repos/browser-harness')
p.write_text(s)
PY
cd /mnt/rpi/repos/browser-harness && PLAYWRIGHT_BASE_URL=http://127.0.0.1:4011 pnpm test:smoke >/tmp/browser-harness-revalidate.log 2>&1 || true
cat /tmp/browser-harness-revalidate.log


> browser-harness@0.1.0 test:smoke /mnt/rpi/repos/browser-harness
> playwright test tests/smoke

Running 2 tests using 2 workers

  ✘  1 [chromium] › tests/smoke/local-web.spec.ts:3:5 › localhost responds over HTTP (140ms)
  ✘  2 [chromium] › tests/smoke/browser-capabilities.spec.ts:3:5 › chromium exposes baseline web platform capabilities (447ms)

  1) [chromium] › tests/smoke/browser-capabilities.spec.ts:3:5 › chromium exposes baseline web platform capabilities

    Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:4011/
    Call log:
      - navigating to "http://127.0.0.1:4011/", waiting until "load"

      2 |
      3 | test('chromium exposes baseline web platform capabilities', async ({ page, baseURL }) => {
    > 4 |   await page.goto(baseURL ?? 'http://127.0.0.1');
        |              ^
      5 |
      6 |   const capabilities = await page.evaluate(async () => {
      7 |     let cacheAvailable = typeof window.caches !== 'undefined';
        at /mnt/rpi/repos/browser-harness/tests/smoke/browser-capabilities.spec.ts:4:14

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/smoke-browser-capabilities-cfc97-e-web-platform-capabilities-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    attachment #2: video (video/webm) ──────────────────────────────────────────────────────────────
    test-results/smoke-browser-capabilities-cfc97-e-web-platform-capabilities-chromium/video.webm
    ────────────────────────────────────────────────────────────────────────────────────────────────

    Error Context: test-results/smoke-browser-capabilities-cfc97-e-web-platform-capabilities-chromium/error-context.md

    attachment #4: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/smoke-browser-capabilities-cfc97-e-web-platform-capabilities-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/smoke-browser-capabilities-cfc97-e-web-platform-capabilities-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  2) [chromium] › tests/smoke/local-web.spec.ts:3:5 › localhost responds over HTTP ─────────────────

    Error: apiRequestContext.get: connect ECONNREFUSED 127.0.0.1:4011
    Call log:
      - → GET http://127.0.0.1:4011/
        - user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.7727.15 Safari/537.36
        - accept: */*
        - accept-encoding: gzip,deflate,br

      2 |
      3 | test('localhost responds over HTTP', async ({ request, baseURL }) => {
    > 4 |   const response = await request.get(baseURL ?? 'http://127.0.0.1');
        |                                  ^
      5 |   expect(response.ok()).toBeTruthy();
      6 | });
      7 |
        at /mnt/rpi/repos/browser-harness/tests/smoke/local-web.spec.ts:4:34

    Error Context: test-results/smoke-local-web-localhost-responds-over-HTTP-chromium/error-context.md

    attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/smoke-local-web-localhost-responds-over-HTTP-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/smoke-local-web-localhost-responds-over-HTTP-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  2 failed
    [chromium] › tests/smoke/browser-capabilities.spec.ts:3:5 › chromium exposes baseline web platform capabilities
    [chromium] › tests/smoke/local-web.spec.ts:3:5 › localhost responds over HTTP ──────────────────
 ELIFECYCLE  Command failed with exit code 1.


set -euo pipefail
python3 -m http.server 4011 --bind 127.0.0.1 >/tmp/browser-harness-http.log 2>&1 &
PID=$!
trap 'kill $PID >/dev/null 2>&1 || true' EXIT
cd /mnt/rpi/repos/browser-harness
PLAYWRIGHT_BASE_URL=http://127.0.0.1:4011 pnpm test:smoke
sed -n '1,40p' /mnt/rpi/pi-config/bin/browser-harness


> browser-harness@0.1.0 test:smoke /mnt/rpi/repos/browser-harness
> playwright test tests/smoke

Running 2 tests using 2 workers

  ✓  2 [chromium] › tests/smoke/local-web.spec.ts:3:5 › localhost responds over HTTP (114ms)
  ✓  1 [chromium] › tests/smoke/browser-capabilities.spec.ts:3:5 › chromium exposes baseline web platform capabilities (453ms)

  2 passed (1.5s)
#!/usr/bin/env bash
set -euo pipefail
cd /mnt/rpi/repos/browser-harness
exec pnpm "$@"


Listo.

Movido a:
- `/mnt/rpi/repos/browser-harness`

Ajustado:
- wrapper `browser-harness` ahora apunta a:
  - `/mnt/rpi/repos/browser-harness`

Revalidado:
- levanté server local temporal
- corrí suite desde la nueva ubicación
- resultado:
  - `2 passed`

Nota:
- el primer intento falló solo porque ya no estaba corriendo el server temporal en `:4011`
- después lo levanté y quedó validado bien

Estado final:
- Playwright shared harness ya está fuera del repo de Ballbox
- ya está bajo `/mnt/rpi`
- listo para uso general

Comando:
- `browser-harness test:smoke`

Si querés, siguiente paso: arranco ahora sí `OPS-2` en branch nueva desde `origin/main`.


cd /mnt/rpi/repos/ballbox && git fetch origin && git status --short --branch && git rev-parse --abbrev-ref HEAD && git rev-parse origin/main


From https://github.com/sebasfavaron/ballbox
   f3d16c1..129e317  FelipeVersion    -> origin/FelipeVersion
 * [new branch]      Iteration2Felipe -> origin/Iteration2Felipe
## main...origin/main
main
95f8b5c8b8520df7424e7d9f0cd55af7a76dfe1f


# Docs Index

## How To Navigate
- `PLAN.md`: current repo execution state and next implementation steps
- `docs/next-steps-queue.md`: prioritized backlog with dependency states
- `docs/repo-map.md`: fast file-level entrypoint for each domain
- `docs/implemented-*.md`: shipped behavior only
- `docs/*-rfc.md`: proposed implementation slices
- `docs/*-plan.md`: route/product planning
- `docs/*-discovery*.md`: validation and research framing
- `docs/*-gap-analysis.md`: what Ballbox still needs to build
- `docs/maintainability-priorities-2026-03.md`: prioritized frontend/backend maintainability work for the next refactor cycle
- `docs/demo-iteration-mode.md`: rapid demo delivery model with anti-debt guardrails

## Operator Priority Path
1. `PLAN.md`
2. `docs/next-steps-queue.md`
3. `docs/repo-map.md`
4. `docs/demo-iteration-mode.md`
5. relevant `docs/implemented-*.md` for the touched domain

## Current Refactor Input
- `docs/maintainability-priorities-2026-03.md`

## Execution And Coordination
- `docs/next-steps-queue.md`
- `docs/repo-map.md`
- `docs/demo-iteration-mode.md`

## Implemented State
- `docs/implemented-host-app.md`
- `docs/implemented-network.md`
- `docs/implemented-payments.md`
- `docs/implemented-players.md`
- `docs/implemented-sports.md`
- `docs/implemented-tvs.md`

## Discovery And Strategy
- `docs/coaches-discovery-stage.md`
- `docs/coaches-form-current.md`
- `docs/atc-integration-discovery.md`
- `docs/coaches-opportunity-brief.md`
- `docs/coaches-research-repository.md`
- `docs/coaches-validation-checklist-2-weeks.md`
- `docs/coaches-solution-thesis.md`

## Current Build Docs
- `docs/classes-subroute-plan.md`
- `docs/classes-mvp-rfc.md`
- `docs/coaches-coordination-gap-analysis.md`
- `docs/atc-mock-integration-plan.md`


# Repo Map

## Purpose
- Fast entrypoint for agents and maintainers to find the right files before editing.

## Top-Level Map
- `app/`: Next.js routes and API handlers.
- `components/`: UI clients and route-level presentation pieces.
- `lib/`: backend/domain modules, integration helpers, contracts, shared runtime helpers.
- `prisma/`: schema and seed sources.
- `tests/`: `contracts`, `unit`, `integration`, `e2e`, and support helpers.
- `docs/`: implemented state, discovery context, plans, and execution queue.

## Product Domains

### TVs
- UI surfaces: `app/admin/tvs`, `app/admin/screens`, `app/player/[screenId]/route.ts`
- APIs: `app/api/tvs/*`
- Backend: `lib/tvs-backend-*.ts`, `lib/tvs-player-runtime-core.ts`, `lib/tvs-runtime.ts`
- Implemented doc: `docs/implemented-tvs.md`

### Payments
- UI surface: `app/admin/payments`
- APIs: `app/api/admin/payments/*`, `app/api/integrations/payments/*`
- Backend: `lib/payments.ts`, `lib/payments-auth.ts`, `lib/shared-secret.ts`, `lib/integrations.ts`
- Implemented doc: `docs/implemented-payments.md`

### Network
- UI surface: `app/admin/network`
- APIs: `app/api/network*`
- Backend: `lib/network-backend-*.ts`
- Implemented doc: `docs/implemented-network.md`

### Sports / Classes / Coaches
- UI surfaces: `app/admin/sports`, `app/classes/*`, `app/classes-by-date`, `app/coaches/settings`
- APIs: `app/api/sports`, `app/api/classes/*`, `app/api/coaches/*`
- Backend: `lib/sports-backend-*.ts`, `lib/classes-backend.ts`, `lib/classes.ts`, `lib/coaches-backend.ts`
- Implemented doc: `docs/implemented-sports.md`

### Players
- UI surface: `app/admin/players`
- APIs: `app/api/players/*`
- Backend: `lib/players-backend.ts`
- Implemented doc: `docs/implemented-players.md`

## Shared Entry Docs
- `PLAN.md`: current state + active next-step queue pointer.
- `docs/index.md`: docs navigation index.
- `docs/demo-iteration-mode.md`: L1/L2/L3 demo delivery model.
- `docs/next-steps-queue.md`: prioritized backlog with dependency states.

## Shared HTTP Helpers
- Route JSON/envelope helpers: `lib/http/json.ts`, `lib/http/network-route-helpers.ts`, `lib/http/payments-route-helpers.ts`, `lib/http/players-route-helpers.ts`, `lib/http/sports-route-helpers.ts`


cd /mnt/rpi/repos/ballbox && git checkout -b ops2-runtime-parity origin/main && git status --short --branch


Switched to a new branch 'ops2-runtime-parity'
branch 'ops2-runtime-parity' set up to track 'origin/main'.
## ops2-runtime-parity...origin/main


import { createEmptyTvManifest } from "@/lib/contracts/tvs";
import { serializeTvPlayerRuntimeCore } from "@/lib/tvs-player-runtime-core";
import { getTvManifestFromSource } from "@/lib/tvs-source";

export const dynamic = "force-dynamic";

const playerStyles = `
  :root {
    color-scheme: dark;
    --bg: #050704;
    --panel: rgba(16, 20, 15, 0.94);
    --panel-strong: rgba(11, 14, 10, 0.98);
    --line: rgba(255, 255, 255, 0.12);
    --text: #f6f8f2;
    --muted: rgba(255, 255, 255, 0.66);
    --accent: #c4d600;
  }

  * { box-sizing: border-box; }
  html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); font-family: ui-sans-serif, system-ui, sans-serif; }
  body { min-height: 100vh; }
  .shell { display: grid; min-height: 100vh; grid-template-columns: minmax(0, 1fr) 360px; }
  .stage { position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #000; min-height: 60vh; }
  .video { width: 100%; height: 100%; object-fit: contain; background: #000; }
  .chip { position: absolute; left: 24px; top: 24px; border: 1px solid rgba(255,255,255,0.2); background: rgba(0,0,0,0.55); border-radius: 999px; padding: 10px 16px; font-size: 14px; backdrop-filter: blur(12px); }
  .panel { display: flex; flex-direction: column; justify-content: space-between; gap: 24px; padding: 24px; border-left: 1px solid var(--line); background: linear-gradient(180deg, #141813, #0c0f0b); }
  .eyebrow { margin: 0; font-size: 11px; letter-spacing: 0.24em; text-transform: uppercase; color: var(--accent); }
  .title { margin: 12px 0 0; font-size: 36px; line-height: 1.1; }
  .copy { margin: 12px 0 0; color: var(--muted); font-size: 14px; }
  .card { margin-top: 24px; border: 1px solid var(--line); border-radius: 24px; background: rgba(255,255,255,0.05); padding: 16px; }
  .diag { margin: 12px 0 0; font-size: 14px; color: var(--muted); }
  .buttons { display: flex; flex-direction: column; gap: 12px; }
  .creative-button { width: 100%; border-radius: 18px; border: 1px solid var(--line); padding: 14px 16px; text-align: left; background: rgba(255,255,255,0.05); color: var(--text); cursor: pointer; }
  .creative-button[aria-current="true"] { border-color: var(--accent); background: rgba(196,214,0,0.12); }
  .creative-id { margin-top: 6px; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: rgba(255,255,255,0.45); }
  .top-links { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
  .top-link { color: var(--text); text-decoration: none; border: 1px solid var(--line); border-radius: 999px; padding: 10px 14px; font-size: 13px; }
  button.top-link { background: transparent; cursor: pointer; font: inherit; line-height: 1; }
  .top-link:hover { border-color: rgba(196,214,0,0.45); }
  .empty { display: flex; min-height: 100vh; align-items: center; justify-content: center; padding: 24px; background: #10130e; }
  .empty-card { max-width: 560px; border-radius: 32px; border: 1px solid var(--line); background: rgba(16, 20, 15, 0.92); padding: 32px; text-align: center; }
  .empty-card h1 { margin: 12px 0 0; font-size: 40px; }
  .empty-card p { margin: 12px 0 0; color: var(--muted); line-height: 1.6; }
  @media (max-width: 960px) {
    .shell { grid-template-columns: 1fr; }
    .panel { border-left: 0; border-top: 1px solid var(--line); }
  }
`;

function escapeHtml(value: string) {
  return value
    .replaceAll("&", "&")
    .replaceAll("<", "<")
    .replaceAll(">", ">")
    .replaceAll('"', """)
    .replaceAll("'", "'");
}

function serializeForScript(value: unknown) {
  return JSON.stringify(value).replaceAll("<", "\\u003c").replaceAll("</script", "<\\/script");
}

function renderMissingScreenHtml(screenId: string) {
  return `<!DOCTYPE html>

    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Player not found</title>

Lightweight player

Pantalla no encontrada

No existe una pantalla con id ${escapeHtml(screenId)}.

          Volver a screens

`;
}

function renderPlayerHtml(bootstrap: {
  screenId: string;
  manifest: ReturnType<typeof createEmptyTvManifest>;
}) {
  return `<!DOCTYPE html>

    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Ballbox Player ${escapeHtml(bootstrap.screenId)}</title>

        <video id="player-video" class="video" autoplay muted loop playsinline controls></video>

${escapeHtml(bootstrap.screenId)}

Lightweight player

Loading

Preparing runtime

            Manifest

Diagnostics

Shell: lightweight-html-js

              <button class="top-link" id="diag-export" type="button">Export parity snapshot</button>

`;
}

export async function GET(
  _request: Request,
  context: { params: Promise<{ screenId: string }> }
) {
  const { screenId } = await context.params;
  const manifest = await getTvManifestFromSource(screenId);

  if (!manifest.ok && manifest.error === "SCREEN_NOT_FOUND") {
    return new Response(renderMissingScreenHtml(screenId), {
      status: 404,
      headers: {
        "content-type": "text/html; charset=utf-8",
        "cache-control": "no-store",
      },
    });
  }

  const initialManifest = !manifest.ok ? createEmptyTvManifest(screenId) : manifest;

  return new Response(
    renderPlayerHtml({
      screenId,
      manifest: initialManifest,
    }),
    {
      headers: {
        "content-type": "text/html; charset=utf-8",
        "cache-control": "no-store",
      },
    }
  );
}


import type { ImpressionRetryItem, ScreenManifest } from "@/lib/tvs-runtime";

export type TvPlayerRuntimeState = {
  manifest: ScreenManifest;
  activeIndex: number;
  lastImpressionId: string | null;
  lastHeartbeatAt: string | null;
  cachedMediaSources: Record<string, string>;
  cacheApiAvailable: boolean;
  cachedCreativeCount: number;
  lastManifestRefreshAt: string | null;
  lastManifestRefreshStatus: "pending" | "ok" | "failed";
  queuedImpressions: number;
  runtimeMode: "preparing" | "streaming" | "cached";
  mediaSyncPending: boolean;
};

type RuntimeResult = { ok: true } | { ok: false; status?: number };

export function createTvPlayerRuntimeCore({
  screenId,
  initialManifest,
}: {
  screenId: string;
  initialManifest: ScreenManifest;
}) {
  const TVS_MEDIA_CACHE_PREFIX = "ballbox-tvs-media";
  const TVS_IMPRESSION_QUEUE_PREFIX = "ballbox-tvs-impressions";
  const MAX_IMPRESSION_RETRY_ITEMS = 50;
  const MAX_IMPRESSION_RETRY_ATTEMPTS = 5;

  const objectUrlMap = new Map<string, string>();
  const listeners = new Set<() => void>();
  let disposed = false;
  let mediaSyncVersion = 0;

  let state: TvPlayerRuntimeState = {
    manifest: initialManifest,
    activeIndex: 0,
    lastImpressionId: null,
    lastHeartbeatAt: null,
    cachedMediaSources: {},
    cacheApiAvailable: false,
    cachedCreativeCount: 0,
    lastManifestRefreshAt: null,
    lastManifestRefreshStatus: "pending",
    queuedImpressions: 0,
    runtimeMode: "preparing",
    mediaSyncPending: true,
  };

  function emit() {
    if (disposed) {
      return;
    }

    for (const listener of listeners) {
      listener();
    }
  }

  function patchState(partial: Partial<TvPlayerRuntimeState>) {
    state = { ...state, ...partial };
    emit();
  }

  function normalizeCreativeUrl(url: string) {
    if (url.startsWith("http://") || url.startsWith("https://") || url.startsWith("/")) {
      return url;
    }

    return `/${url}`;
  }

  function getTvMediaCacheName(id: string) {
    return `${TVS_MEDIA_CACHE_PREFIX}:${id}`;
  }

  function getTvImpressionQueueStorageKey(id: string) {
    return `${TVS_IMPRESSION_QUEUE_PREFIX}:${id}`;
  }

  function getActiveCreativeIndex(currentManifest: ScreenManifest, nextManifest: ScreenManifest, currentIndex: number) {
    if (!nextManifest.creatives.length) {
      return 0;
    }

    const currentCreative = currentManifest.creatives[currentIndex];
    if (!currentCreative) {
      return 0;
    }

    const nextIndex = nextManifest.creatives.findIndex((creative) => creative.id === currentCreative.id);
    return nextIndex >= 0 ? nextIndex : 0;
  }

  function createImpressionRetryItem(id: string, creativeId: string) {
    const queuedAt = new Date().toISOString();
    return {
      id: `${id}:${creativeId}:${queuedAt}`,
      screenId: id,
      creativeId,
      queuedAt,
      attempts: 0,
    } satisfies ImpressionRetryItem;
  }

  function enqueueImpressionRetry(queue: ImpressionRetryItem[], item: ImpressionRetryItem) {
    const nextQueue = queue.concat(item);
    if (nextQueue.length <= MAX_IMPRESSION_RETRY_ITEMS) {
      return nextQueue;
    }

    return nextQueue.slice(nextQueue.length - MAX_IMPRESSION_RETRY_ITEMS);
  }

  function advanceImpressionRetry(queue: ImpressionRetryItem[], itemId: string) {
    return queue
      .map((item) => (item.id === itemId ? { ...item, attempts: item.attempts + 1 } : item))
      .filter((item) => item.attempts < MAX_IMPRESSION_RETRY_ATTEMPTS);
  }

  function removeImpressionRetry(queue: ImpressionRetryItem[], itemId: string) {
    return queue.filter((item) => item.id !== itemId);
  }

  function shouldRetryImpression(status?: number) {
    if (!status) {
      return true;
    }

    return status >= 500;
  }

  function readQueue() {
    try {
      const raw = window.localStorage.getItem(getTvImpressionQueueStorageKey(screenId));
      if (!raw) {
        return [] as ImpressionRetryItem[];
      }

      const parsed = JSON.parse(raw) as unknown;
      if (!Array.isArray(parsed)) {
        return [] as ImpressionRetryItem[];
      }

      return parsed.filter((item): item is ImpressionRetryItem => {
        if (!item || typeof item !== "object") {
          return false;
        }

        const candidate = item as Partial<ImpressionRetryItem>;
        return (
          typeof candidate.id === "string" &&
          typeof candidate.screenId === "string" &&
          typeof candidate.creativeId === "string" &&
          typeof candidate.queuedAt === "string" &&
          typeof candidate.attempts === "number"
        );
      });
    } catch {
      return [] as ImpressionRetryItem[];
    }
  }

  function writeQueue(queue: ImpressionRetryItem[]) {
    window.localStorage.setItem(getTvImpressionQueueStorageKey(screenId), JSON.stringify(queue));
    patchState({ queuedImpressions: queue.length });
  }

  function getSnapshot() {
    return state;
  }

  function subscribe(listener: () => void) {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  }

  function getActiveCreative() {
    return state.manifest.creatives[state.activeIndex] ?? null;
  }

  function setActiveIndex(nextIndex: number) {
    const creatives = state.manifest.creatives;
    if (!creatives.length) {
      patchState({ activeIndex: 0 });
      return;
    }

    const boundedIndex = ((nextIndex % creatives.length) + creatives.length) % creatives.length;
    patchState({ activeIndex: boundedIndex });
  }

  function rotateToNextCreative() {
    if (state.manifest.creatives.length <= 1) {
      return;
    }

    setActiveIndex(state.activeIndex + 1);
  }

  async function syncCachedMedia() {
    const syncVersion = ++mediaSyncVersion;
    const hasCacheApi = "caches" in window;

    patchState({
      cacheApiAvailable: hasCacheApi,
      mediaSyncPending: true,
      runtimeMode: hasCacheApi ? "preparing" : "streaming",
    });

    if (!hasCacheApi) {
      patchState({
        cachedMediaSources: {},
        cachedCreativeCount: 0,
        mediaSyncPending: false,
        runtimeMode: "streaming",
      });
      return;
    }

    const cache = await caches.open(getTvMediaCacheName(screenId));
    const validUrls = new Set(state.manifest.creatives.map((creative) => normalizeCreativeUrl(creative.url)));
    const keys = await cache.keys();

    await Promise.all(
      keys
        .filter((request) => !validUrls.has(new URL(request.url).pathname))
        .map((request) => cache.delete(request))
    );

    const nextSources: Record<string, string> = {};
    const nextIds = new Set(state.manifest.creatives.map((creative) => creative.id));

    for (const creative of state.manifest.creatives) {
      const normalizedUrl = normalizeCreativeUrl(creative.url);
      let cachedResponse = await cache.match(normalizedUrl);

      if (!cachedResponse) {
        try {
          const response = await fetch(normalizedUrl, { cache: "no-store" });
          if (response.ok) {
            await cache.put(normalizedUrl, response.clone());
            cachedResponse = response;
          }
        } catch {
          // keep remote fallback when prefetch fails
        }
      }

      if (!cachedResponse) {
        continue;
      }

      const existingObjectUrl = objectUrlMap.get(creative.id);
      if (existingObjectUrl) {
        nextSources[creative.id] = existingObjectUrl;
        continue;
      }

      const blob = await cachedResponse.blob();
      const objectUrl = URL.createObjectURL(blob);
      objectUrlMap.set(creative.id, objectUrl);
      nextSources[creative.id] = objectUrl;
    }

    for (const [creativeId, objectUrl] of objectUrlMap.entries()) {
      if (nextIds.has(creativeId)) {
        continue;
      }

      URL.revokeObjectURL(objectUrl);
      objectUrlMap.delete(creativeId);
    }

    if (disposed || syncVersion !== mediaSyncVersion) {
      return;
    }

    patchState({
      cachedMediaSources: nextSources,
      cachedCreativeCount: Object.keys(nextSources).length,
      mediaSyncPending: false,
      runtimeMode: Object.keys(nextSources).length > 0 ? "cached" : "streaming",
    });
  }

  async function sendHeartbeat() {
    try {
      const response = await fetch("/api/tvs/heartbeat", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ screenId }),
      });

      if (!response.ok) {
        return;
      }

      const payload = (await response.json()) as { lastSeen?: string };
      patchState({ lastHeartbeatAt: payload.lastSeen ?? null });
    } catch {
      // runtime should continue even when reporting fails
    }
  }

  async function reportImpression(creativeId: string): Promise<RuntimeResult> {
    try {
      const response = await fetch("/api/tvs/impression", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          screenId,
          creativeId,
        }),
      });

      if (!response.ok) {
        return {
          ok: false,
          status: response.status,
        };
      }

      const payload = (await response.json()) as { impressionId?: string };
      patchState({ lastImpressionId: payload.impressionId ?? null });
      return { ok: true };
    } catch {
      return {
        ok: false,
        status: undefined,
      };
    }
  }

  async function flushQueue() {
    let queue = readQueue();
    if (!queue.length) {
      patchState({ queuedImpressions: 0 });
      return;
    }

    for (const item of queue) {
      const result = await reportImpression(item.creativeId);
      if (result.ok) {
        queue = removeImpressionRetry(queue, item.id);
        continue;
      }

      if (!shouldRetryImpression(result.status)) {
        queue = removeImpressionRetry(queue, item.id);
        continue;
      }

[82 more lines in file. Use offset=361 to continue.]


import { expect, test } from "@playwright/test";

test("lightweight player keeps cached playback when browser goes offline", async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  const pageErrors: Error[] = [];

  page.on("pageerror", (error) => {
    pageErrors.push(error);
  });

  await page.goto("/player/screen_001");

  await expect(page.getByText("Shell: lightweight-html-js")).toBeVisible({ timeout: 20_000 });
  await expect(page.getByText("Playback: cached")).toBeVisible({ timeout: 20_000 });
  await expect(page.getByText("Active source: blob")).toBeVisible();
  await expect(page.getByText("Cache API: available")).toBeVisible();
  await expect(page.getByText(/^Cached creatives: [1-9]\d*$/)).toBeVisible();
  await expect(page.getByText("Network: online")).toBeVisible();

  const video = page.locator("video");
  await expect(video).toHaveAttribute("src", /blob:/);

  await context.setOffline(true);
  await page.waitForTimeout(3_000);

  await expect(page.getByText("Playback: cached")).toBeVisible();
  await expect(page.getByText("Active source: blob")).toBeVisible();
  await expect(page.getByText("Network: offline")).toBeVisible();
  await expect(video).toHaveAttribute("src", /blob:/);
  expect(pageErrors).toEqual([]);

  await context.close();
});


import { readFileSync } from "node:fs";
import { PrismaClient } from "@prisma/client";
import { expect, test } from "@playwright/test";

if (!process.env.DATABASE_URL) {
  const envLines = readFileSync(".env.local", "utf8").split(/\r?\n/);
  for (const line of envLines) {
    if (!line || line.startsWith("#") || !line.includes("=")) continue;

    const separatorIndex = line.indexOf("=");
    const key = line.slice(0, separatorIndex).trim();
    const rawValue = line.slice(separatorIndex + 1).trim();
    const value = rawValue.replace(/^['"]|['"]$/g, "");

    if (!(key in process.env)) {
      process.env[key] = value;
    }
  }
}

const prisma = new PrismaClient();

test.afterAll(async () => {
  await prisma.$disconnect();
});

test("lightweight player records heartbeat and impression side effects", async ({ page }) => {
  const testStartedAt = new Date();

  const heartbeatResponsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/heartbeat")
  );
  const impressionResponsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/impression")
  );

  await page.goto("/player/screen_001");

  const heartbeatResponse = await heartbeatResponsePromise;
  const impressionResponse = await impressionResponsePromise;

  expect(heartbeatResponse.status()).toBe(200);
  expect(impressionResponse.status()).toBe(200);

  const heartbeatBody = (await heartbeatResponse.json()) as { ok: boolean; screenId: string; lastSeen?: string };
  const impressionBody = (await impressionResponse.json()) as { ok: boolean; impressionId?: string };
  const impressionRequestBody = impressionResponse.request().postDataJSON() as { screenId: string; creativeId: string };

  expect(heartbeatBody.ok).toBe(true);
  expect(heartbeatBody.screenId).toBe("screen_001");
  expect(heartbeatBody.lastSeen).toBeTruthy();
  expect(impressionBody.ok).toBe(true);
  expect(impressionBody.impressionId).toBeTruthy();
  expect(impressionRequestBody.screenId).toBe("screen_001");
  expect(impressionRequestBody.creativeId).toBeTruthy();

  await expect(page.getByText(/^Last heartbeat:/)).not.toContainText("pending");
  await expect(page.getByText(/^Last impression:/)).not.toContainText("pending");
  await expect(page.getByText("Queued impressions: 0")).toBeVisible();

  const screen = await prisma.screen.findUnique({
    where: { id: "screen_001" },
    select: { tvDevice: { select: { id: true, lastSeen: true } } },
  });

  expect(screen?.tvDevice?.id).toBeTruthy();

  await expect
    .poll(async () => {
      const tvDevice = await prisma.tvDevice.findUnique({
        where: { id: screen!.tvDevice!.id },
        select: { lastSeen: true },
      });
      return tvDevice?.lastSeen?.getTime() ?? 0;
    })
    .toBeGreaterThan(testStartedAt.getTime());

  await expect
    .poll(async () => {
      const impression = await prisma.impression.findFirst({
        where: {
          tvDeviceId: screen!.tvDevice!.id,
          creativeId: impressionRequestBody.creativeId,
          shownAt: { gt: testStartedAt },
        },
        orderBy: { shownAt: "desc" },
      });

      return impression?.id ?? null;
    })
    .not.toBeNull();
});


# TVs Implemented State

## Admin
- `/admin/tvs` renders inside Ballbox.
- Ballbox owns the canonical TVs entity model for clubs, venues, screens, campaigns, assignments, tv devices, and impressions.
- TVs admin/API reads from Ballbox Postgres through Prisma.
- No Streetcast compatibility layer or in-memory TVs bridge remains in runtime code.
- `/admin/tvs` links each screen directly to its runtime and manifest.
- `/admin/tvs` now supports admin CRUD for screens and tv devices through Ballbox-owned admin APIs.
- `/admin/tvs` now supports admin CRUD for campaigns, creatives, and screen assignments through Ballbox-owned admin APIs.

## APIs
- `/api/tvs/health`
- `/api/tvs/screens`
- `/api/tvs/manifest/[screenId]`
- `/api/tvs/heartbeat`
- `/api/tvs/impression`
- `/api/admin/tvs/operations`
- `/api/admin/tvs/screens`
- `/api/admin/tvs/screens/[screenId]`
- `/api/admin/tvs/tv-devices`
- `/api/admin/tvs/tv-devices/[tvDeviceId]`
- `/api/admin/tvs/campaigns`
- `/api/admin/tvs/campaigns/[campaignId]`
- `/api/admin/tvs/campaigns/[campaignId]/creatives`
- `/api/admin/tvs/creatives/[creativeId]`
- `/api/admin/tvs/campaigns/[campaignId]/assignments`
- `/api/admin/tvs/assignments/[assignmentId]`
- TVs API contract is now defined from `Zod` schemas in `lib/contracts/tvs.ts`.
- Breaking changes in TVs responses are expected to fail `pnpm test:contracts`.
- Error responses use a strict shared shape: `{ ok: false, error, status }`.

## Runtime
- `/admin/screens` lists available screens.
- `/player/[screenId]` renders the lightweight TV runtime path served as plain HTML + JavaScript.
- The runtime rotates creatives from a Ballbox DB-backed manifest derived from active screen-to-campaign assignments.
- The runtime posts a heartbeat to Ballbox every 30 seconds, independent of manifest refreshes.
- Heartbeats persist to the linked Ballbox `TvDevice.lastSeen`.
- Manifest generation respects campaign `startAt`/`endAt` and assignment `startsAt`/`endsAt` windows.
- Runtime now warms a screen-scoped browser media cache and prefers cached blob playback when available.
- Runtime keeps the last good manifest during transient manifest fetch failures so cached media can continue playing offline.
- Runtime preloads manifest creatives after refresh and evicts stale cached media when programming changes.
- Cache/media/impression/heartbeat/manifest-refresh behavior now comes from one shared runtime core in `lib/tvs-player-runtime-core.ts`; React and lightweight HTML+JS are delivery adapters over the same logic.
- Browser e2e coverage now verifies cached blob playback survives an offline transition on `/player/[screenId]`.
- Runtime now exposes operator-facing diagnostics for playback source, Cache API support, cached creative count, network state, and manifest refresh status to support real-device parity checks.
- Runtime diagnostics now include user-agent plus capture timestamp and a built-in `Export parity snapshot` action that downloads a JSON evidence file for real-device parity runs.
- TVs admin and `/admin/screens` link operators to the lightweight player path.
- This is the main special-case surface in Ballbox: TVs stay inside the same repo/deploy/domain, but the device runtime is intentionally served outside the Next client/React runtime as plain HTML + JavaScript.
- `GET /api/tvs/manifest/[screenId]` returns:
  - `404` when the screen does not exist
  - `409` when the screen exists but has no active programming

## Reporting
- The runtime posts impressions to Ballbox using `/api/tvs/impression`.
- Ballbox records impressions directly in Ballbox Postgres.
- A successful impression response returns a Ballbox `impressionId`.
- `POST /api/tvs/impression` now returns `409` when the creative exists but is not active for the target screen.
- Failed runtime impression writes now enter a bounded browser retry queue and flush on a timer plus browser `online` events.

## Bootstrap
- TVs demo/bootstrap data still lives in `prisma/seed-data.ts`.
- Running `pnpm db:seed` upserts the TVs base dataset and prints a seed summary with entity counts.
- Post-`pnpm db:push` plus `pnpm db:seed` verification confirms Ballbox Postgres keeps canonical `Screen.vendingMachineId` and `Impression.tvDeviceId`, with no legacy `deviceId` column reintroduced.

## Contract Enforcement
- `TVS_CONTRACT_VERSION` currently lives in `lib/contracts/tvs.ts`.
- Contract tests cover:
  - route success/error shapes
  - scheduling invariants
  - strict schema parsing with no extra fields
  - runtime helper invariants for cache naming, manifest continuity, and bounded retry behavior
- Playwright e2e covers:
  - lightweight `/player/[screenId]` cached blob playback and runtime side effects
  - admin CRUD for screens, tv devices, campaigns, creatives, and assignments
  - cached blob playback on a real browser engine
  - runtime continuity after the browser context goes offline
  - browser-level heartbeat and impression side effects against the real Ballbox DB
  - absence of page-level runtime errors during the offline transition


cd /mnt/rpi/repos/ballbox && cat package.json | sed -n '1,220p'

cd /mnt/rpi/repos/ballbox && sed -n '1,240p' tests/e2e/tvs-offline.spec.ts && printf '\n---FILE---\n' && sed -n '1,260p' tests/e2e/tvs-runtime-side-effects.spec.ts

cd /mnt/rpi/repos/ballbox && sed -n '1,260p' lib/tvs-player-runtime-core.ts | tail -n 120


dotenv -e .env.local -- tsx scripts/backfill-atc-clubs.ts

dotenv -e .env.local -- tsx scripts/revalidate-atc-clubs.ts

dotenv -e .env.local -- tsx prisma/seed.ts

vitest run tests/integration --passWithNoTests


import { expect, test } from "@playwright/test";

test("screen runtime keeps cached playback when browser goes offline", async ({ browser }) => {
  const context = await browser.newContext();
  const page = await context.newPage();
  const pageErrors: Error[] = [];

  page.on("pageerror", (error) => {
    pageErrors.push(error);
  });

  await page.goto("/player/screen_001");

  await expect(page.getByText("Playback: cached")).toBeVisible({ timeout: 20_000 });
  await expect(page.getByText("Active source: blob")).toBeVisible();
  await expect(page.getByText("Cache API: available")).toBeVisible();
  await expect(page.getByText(/^Cached creatives: [1-9]\d*$/)).toBeVisible();
  await expect(page.getByText("Network: online")).toBeVisible();

  const video = page.locator("video");
  await expect(video).toHaveAttribute("src", /blob:/);

  await context.setOffline(true);
  await page.waitForTimeout(3_000);

  await expect(page.getByText("Playback: cached")).toBeVisible();
  await expect(page.getByText("Active source: blob")).toBeVisible();
  await expect(page.getByText("Network: offline")).toBeVisible();
  await expect(video).toHaveAttribute("src", /blob:/);
  expect(pageErrors).toEqual([]);

  await context.close();
});

---FILE---
import { readFileSync } from "node:fs";
import { PrismaClient } from "@prisma/client";
import { expect, test } from "@playwright/test";

if (!process.env.DATABASE_URL) {
  const envLines = readFileSync(".env.local", "utf8").split(/\r?\n/);
  for (const line of envLines) {
    if (!line || line.startsWith("#") || !line.includes("=")) continue;

    const separatorIndex = line.indexOf("=");
    const key = line.slice(0, separatorIndex).trim();
    const rawValue = line.slice(separatorIndex + 1).trim();
    const value = rawValue.replace(/^['"]|['"]$/g, "");

    if (!(key in process.env)) {
      process.env[key] = value;
    }
  }
}

const prisma = new PrismaClient();

test.afterAll(async () => {
  await prisma.$disconnect();
});

test("screen runtime records heartbeat and impression side effects", async ({ page }) => {
  const testStartedAt = new Date();

  const heartbeatResponsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/heartbeat")
  );
  const impressionResponsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/impression")
  );

  await page.goto("/player/screen_001");

  const heartbeatResponse = await heartbeatResponsePromise;
  const impressionResponse = await impressionResponsePromise;

  expect(heartbeatResponse.status()).toBe(200);
  expect(impressionResponse.status()).toBe(200);

  const heartbeatBody = (await heartbeatResponse.json()) as { ok: boolean; screenId: string; lastSeen?: string };
  const impressionBody = (await impressionResponse.json()) as { ok: boolean; impressionId?: string };
  const impressionRequestBody = impressionResponse.request().postDataJSON() as { screenId: string; creativeId: string };

  expect(heartbeatBody.ok).toBe(true);
  expect(heartbeatBody.screenId).toBe("screen_001");
  expect(heartbeatBody.lastSeen).toBeTruthy();
  expect(impressionBody.ok).toBe(true);
  expect(impressionBody.impressionId).toBeTruthy();
  expect(impressionRequestBody.screenId).toBe("screen_001");
  expect(impressionRequestBody.creativeId).toBeTruthy();

  await expect(page.getByText(/^Last heartbeat:/)).not.toContainText("pending");
  await expect(page.getByText(/^Last impression:/)).not.toContainText("pending");
  await expect(page.getByText("Queued impressions: 0")).toBeVisible();

  const screen = await prisma.screen.findUnique({
    where: { id: "screen_001" },
    select: { tvDevice: { select: { id: true, lastSeen: true } } },
  });

  expect(screen?.tvDevice?.id).toBeTruthy();

  await expect
    .poll(async () => {
      const tvDevice = await prisma.tvDevice.findUnique({
        where: { id: screen!.tvDevice!.id },
        select: { lastSeen: true },
      });
      return tvDevice?.lastSeen?.getTime() ?? 0;
    })
    .toBeGreaterThan(testStartedAt.getTime());

  await expect
    .poll(async () => {
      const impression = await prisma.impression.findFirst({
        where: {
          tvDeviceId: screen!.tvDevice!.id,
          creativeId: impressionRequestBody.creativeId,
          shownAt: { gt: testStartedAt },
        },
        orderBy: { shownAt: "desc" },
      });

      return impression?.id ?? null;
    })
    .not.toBeNull();
});


      const parsed = JSON.parse(raw) as unknown;
      if (!Array.isArray(parsed)) {
        return [] as ImpressionRetryItem[];
      }

      return parsed.filter((item): item is ImpressionRetryItem => {
        if (!item || typeof item !== "object") {
          return false;
        }

        const candidate = item as Partial<ImpressionRetryItem>;
        return (
          typeof candidate.id === "string" &&
          typeof candidate.screenId === "string" &&
          typeof candidate.creativeId === "string" &&
          typeof candidate.queuedAt === "string" &&
          typeof candidate.attempts === "number"
        );
      });
    } catch {
      return [] as ImpressionRetryItem[];
    }
  }

  function writeQueue(queue: ImpressionRetryItem[]) {
    window.localStorage.setItem(getTvImpressionQueueStorageKey(screenId), JSON.stringify(queue));
    patchState({ queuedImpressions: queue.length });
  }

  function getSnapshot() {
    return state;
  }

  function subscribe(listener: () => void) {
    listeners.add(listener);
    return () => {
      listeners.delete(listener);
    };
  }

  function getActiveCreative() {
    return state.manifest.creatives[state.activeIndex] ?? null;
  }

  function setActiveIndex(nextIndex: number) {
    const creatives = state.manifest.creatives;
    if (!creatives.length) {
      patchState({ activeIndex: 0 });
      return;
    }

    const boundedIndex = ((nextIndex % creatives.length) + creatives.length) % creatives.length;
    patchState({ activeIndex: boundedIndex });
  }

  function rotateToNextCreative() {
    if (state.manifest.creatives.length <= 1) {
      return;
    }

    setActiveIndex(state.activeIndex + 1);
  }

  async function syncCachedMedia() {
    const syncVersion = ++mediaSyncVersion;
    const hasCacheApi = "caches" in window;

    patchState({
      cacheApiAvailable: hasCacheApi,
      mediaSyncPending: true,
      runtimeMode: hasCacheApi ? "preparing" : "streaming",
    });

    if (!hasCacheApi) {
      patchState({
        cachedMediaSources: {},
        cachedCreativeCount: 0,
        mediaSyncPending: false,
        runtimeMode: "streaming",
      });
      return;
    }

    const cache = await caches.open(getTvMediaCacheName(screenId));
    const validUrls = new Set(state.manifest.creatives.map((creative) => normalizeCreativeUrl(creative.url)));
    const keys = await cache.keys();

    await Promise.all(
      keys
        .filter((request) => !validUrls.has(new URL(request.url).pathname))
        .map((request) => cache.delete(request))
    );

    const nextSources: Record<string, string> = {};
    const nextIds = new Set(state.manifest.creatives.map((creative) => creative.id));

    for (const creative of state.manifest.creatives) {
      const normalizedUrl = normalizeCreativeUrl(creative.url);
      let cachedResponse = await cache.match(normalizedUrl);

      if (!cachedResponse) {
        try {
          const response = await fetch(normalizedUrl, { cache: "no-store" });
          if (response.ok) {
            await cache.put(normalizedUrl, response.clone());
            cachedResponse = response;
          }
        } catch {
          // keep remote fallback when prefetch fails
        }
      }

      if (!cachedResponse) {
        continue;
      }

      const existingObjectUrl = objectUrlMap.get(creative.id);
      if (existingObjectUrl) {
        nextSources[creative.id] = existingObjectUrl;


cd /mnt/rpi/repos/ballbox && sed -n '361,520p' lib/tvs-player-runtime-core.ts


      queue = advanceImpressionRetry(queue, item.id);
    }

    writeQueue(queue);
  }

  async function queueImpressionForCreative(creativeId: string) {
    const result = await reportImpression(creativeId);
    if (result.ok || !shouldRetryImpression(result.status)) {
      return result;
    }

    const queue = enqueueImpressionRetry(readQueue(), createImpressionRetryItem(screenId, creativeId));
    writeQueue(queue);
    return result;
  }

  async function refreshManifest() {
    try {
      const response = await fetch(`/api/tvs/manifest/${screenId}`, {
        cache: "no-store",
      });

      if (!response.ok) {
        patchState({ lastManifestRefreshStatus: "failed" });
        return;
      }

      const nextManifest = (await response.json()) as ScreenManifest;
      state = {
        ...state,
        manifest: nextManifest,
        activeIndex: getActiveCreativeIndex(state.manifest, nextManifest, state.activeIndex),
        lastManifestRefreshAt: new Date().toISOString(),
        lastManifestRefreshStatus: "ok",
      };
      emit();
      await syncCachedMedia();
    } catch {
      patchState({ lastManifestRefreshStatus: "failed" });
    }
  }

  function syncQueuedImpressions() {
    patchState({ queuedImpressions: readQueue().length });
  }

  function disposeRuntime() {
    disposed = true;
    listeners.clear();

    for (const objectUrl of objectUrlMap.values()) {
      URL.revokeObjectURL(objectUrl);
    }
    objectUrlMap.clear();
  }

  syncQueuedImpressions();

  return {
    getSnapshot,
    subscribe,
    getActiveCreative,
    setActiveIndex,
    rotateToNextCreative,
    syncCachedMedia,
    sendHeartbeat,
    reportImpression,
    flushQueue,
    queueImpressionForCreative,
    refreshManifest,
    syncQueuedImpressions,
    disposeRuntime,
    normalizeCreativeUrl,
  };
}

export function serializeTvPlayerRuntimeCore() {
  return `(${createTvPlayerRuntimeCore.toString()})`;
}


cd /mnt/rpi/repos/ballbox && sed -n '1,260p' tests/e2e/support/test-fixtures.ts && printf '\n---CFG---\n' && sed -n '1,260p' playwright.config.ts


export const E2E_CLUB_SLUG_PREFIX = "pw-e2e-club-";
export const E2E_CLUB_NAME_PREFIX = "PW E2E Club";
export const E2E_ATC_SPORTCLUB_ID_PREFIX = "pw-e2e-atc-";
export const E2E_ATC_SPORTCLUB_ID = `${E2E_ATC_SPORTCLUB_ID_PREFIX}106`;
export const E2E_VENUE_SLUG_PREFIX = "pw-e2e-venue-";
export const E2E_VENUE_NAME_PREFIX = "PW E2E Venue";
export const E2E_MACHINE_LABEL_PREFIX = "PW E2E Machine";
export const E2E_PAYMENT_SESSION_PREFIX = "pw-e2e-session-";
export const E2E_PLAYER_NAME_PREFIX = "PW E2E Player";
export const E2E_COACH_NAME_PREFIX = "PW E2E Coach";
export const E2E_OPEN_MATCH_TITLE_PREFIX = "PW E2E Open Match";
export const E2E_CLASS_TITLE_PREFIX = "PW E2E Class";
export const E2E_SCREEN_NAME_PREFIX = "PW E2E Screen";
export const E2E_TV_DEVICE_NAME_PREFIX = "PW E2E TV Device";
export const E2E_CAMPAIGN_NAME_PREFIX = "PW E2E Campaign";
export const E2E_ADVERTISER_NAME_PREFIX = "PW E2E Advertiser";
export const E2E_CREATIVE_URL_PREFIX = "/videos/pw-e2e-creative-";

export function createE2ESuffix() {
  return Date.now().toString().slice(-6);
}

---CFG---
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  globalSetup: "./tests/e2e/support/playwright-global-cleanup.ts",
  globalTeardown: "./tests/e2e/support/playwright-global-cleanup.ts",
  testDir: "./tests/e2e",
  reporter: "list",
  timeout: 45_000,
  use: {
    baseURL: "http://127.0.0.1:3010",
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],
  webServer: {
    command: "pnpm dev",
    url: "http://127.0.0.1:3010",
    reuseExistingServer: true,
    stdout: "pipe",
    stderr: "pipe",
    env: {
      ...process.env,
      BALLBOX_ADMIN_PASSWORD: "",
      ATC_PUBLIC_API_BASE_URL: "http://127.0.0.1:3010/api/atc-public",
      ATC_PUBLIC_VALIDATE: "false",
    },
    timeout: 120_000,
  },
});


cd /mnt/rpi/repos/ballbox && pnpm db:push && pnpm db:seed

cd /mnt/rpi/repos/ballbox && pnpm playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts


> my-v0-project@0.1.0 db:push /mnt/rpi/repos/ballbox
> dotenv -e .env.local -- prisma db push

Prisma schema loaded from prisma/schema.prisma
Datasource "db": PostgreSQL database "neondb", schema "public" at "ep-still-bird-acym9q2m-pooler.sa-east-1.aws.neon.tech"

⚠  There might be data loss when applying the changes:

  • A unique constraint covering the columns `[atcSportclubId]` on the table `Club` will be added. If there are existing duplicate values, this will fail.

Error: Use the --accept-data-loss flag to ignore the data loss warnings like prisma db push --accept-data-loss
 ELIFECYCLE  Command failed with exit code 1.

Command exited with code 1


[WebServer]
[WebServer] > my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
[WebServer] > next dev --port 3010
[WebServer]
[WebServer] ▲ Next.js 16.2.1 (Turbopack)
[WebServer] - Local:         http://localhost:3010
[WebServer] - Network:       http://192.168.1.33:3010
[WebServer] - Environments: .env.local
[WebServer] ✓ Ready in 637ms
Error: Process from config.webServer exited early.

Command exited with code 1


cd /mnt/rpi/repos/ballbox && pnpm dev > /tmp/ballbox-dev.log 2>&1 & echo $! && sleep 8 && tail -n 80 /tmp/ballbox-dev.log && echo '---' && curl -I -s http://127.0.0.1:3010/ || true && echo '---PORT---' && ss -ltnp | rg ':3010' || true


649422

> my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
> next dev --port 3010

▲ Next.js 16.2.1 (Turbopack)
- Local:         http://localhost:3010
- Network:       http://192.168.1.33:3010
- Environments: .env.local
✓ Ready in 469ms
---
---PORT---


ps -p 649422 -f || true && cat /tmp/ballbox-dev.log


UID          PID    PPID  C STIME TTY          TIME CMD

> my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
> next dev --port 3010

▲ Next.js 16.2.1 (Turbopack)
- Local:         http://localhost:3010
- Network:       http://192.168.1.33:3010
- Environments: .env.local
✓ Ready in 469ms


cd /mnt/rpi/repos/ballbox && pnpm test:e2e -- --list


> my-v0-project@0.1.0 test:e2e /mnt/rpi/repos/ballbox
> playwright test -- --list

[WebServer]
[WebServer] > my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
[WebServer] > next dev --port 3010
[WebServer]
[WebServer] ▲ Next.js 16.2.1 (Turbopack)
[WebServer] - Local:         http://localhost:3010
[WebServer] - Network:       http://192.168.1.33:3010
[WebServer] - Environments: .env.local
[WebServer] ✓ Ready in 473ms
Error: Process from config.webServer exited early.

 ELIFECYCLE  Command failed with exit code 1.

Command exited with code 1


edit

  webServer: {
    command: "pnpm dev",
    url: "http://127.0.0.1:3010",
    reuseExistingServer: true,
    stdout: "pipe",
    stderr: "pipe",
    env: {
      ...process.env,
      BALLBOX_ADMIN_PASSWORD: "",
      ATC_PUBLIC_API_BASE_URL: "http://127.0.0.1:3010/api/atc-public",
      ATC_PUBLIC_VALIDATE: "false",
    },
    timeout: 120_000,
  },

  webServer: {
    command: "pnpm dev",
    url: "http://127.0.0.1:3010",
    reuseExistingServer: true,
    stdout: "pipe",
    stderr: "pipe",
    gracefulShutdown: {
      signal: "SIGKILL",
      timeout: 0,
    },
    env: {
      ...process.env,
      BALLBOX_ADMIN_PASSWORD: "",
      ATC_PUBLIC_API_BASE_URL: "http://127.0.0.1:3010/api/atc-public",
      ATC_PUBLIC_VALIDATE: "false",
    },
    timeout: 120_000,
  },


Successfully replaced 1 block(s) in /mnt/rpi/repos/ballbox/playwright.config.ts.

    ...
 25     url: "http://127.0.0.1:3010",
 26     reuseExistingServer: true,
 27     stdout: "pipe",
 28     stderr: "pipe",
+29     gracefulShutdown: {
+30       signal: "SIGKILL",
+31       timeout: 0,
+32     },
 29     env: {
 30       ...process.env,
 31       BALLBOX_ADMIN_PASSWORD: "",
 32       ATC_PUBLIC_API_BASE_URL: "http://127.0.0.1:3010/api/atc-public",
    ...


> my-v0-project@0.1.0 test:e2e /mnt/rpi/repos/ballbox
> playwright test -- --list

[WebServer]
[WebServer] > my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
[WebServer] > next dev --port 3010
[WebServer]
[WebServer] ▲ Next.js 16.2.1 (Turbopack)
[WebServer] - Local:         http://localhost:3010
[WebServer] - Network:       http://192.168.1.33:3010
[WebServer] - Environments: .env.local
[WebServer] ✓ Ready in 466ms
Error: Process from config.webServer exited early.

 ELIFECYCLE  Command failed with exit code 1.

Command exited with code 1


bash

cd /mnt/rpi/repos/ballbox && (pnpm dev > /tmp/ballbox-dev-manual.log 2>&1 &) && DEV_PID=$! && echo $DEV_PID && sleep 5 && curl -I -s http://127.0.0.1:3010/player/screen_001 | head && echo '---TEST---' && PLAYWRIGHT_TEST_BASE_URL=http://127.0.0.1:3010 pnpm exec playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts --config=playwright.config.ts --workers=1


---TEST---
[WebServer]
[WebServer] > my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
[WebServer] > next dev --port 3010
[WebServer]
[WebServer] ▲ Next.js 16.2.1 (Turbopack)
[WebServer] - Local:         http://localhost:3010
[WebServer] - Network:       http://192.168.1.33:3010
[WebServer] - Environments: .env.local
[WebServer] ✓ Ready in 479ms
Error: Process from config.webServer exited early.

Command exited with code 1


cd /mnt/rpi/repos/ballbox && sed -n '1,240p' tests/e2e/support/playwright-global-cleanup.ts


import { spawnSync } from "node:child_process";

function runCleanup() {
  const result = spawnSync(
    "pnpm",
    ["exec", "dotenv", "-e", ".env.local", "--", "tsx", "tests/e2e/support/cleanup-playwright-db.ts"],
    {
      cwd: process.cwd(),
      encoding: "utf8",
      stdio: "pipe",
    }
  );

  if (result.status !== 0) {
    const message = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
    throw new Error(message || "Playwright DB cleanup failed");
  }
}

export default async function playwrightGlobalCleanup() {
  runCleanup();
}


cd /mnt/rpi/repos/ballbox && pnpm exec dotenv -e .env.local -- tsx tests/e2e/support/cleanup-playwright-db.ts


(no output)


cd /mnt/rpi/repos/ballbox && mkdir -p .codex && cat > .codex/playwright.ops2.config.ts <<'EOF'
import base from "../playwright.config";

export default {
  ...base,
  webServer: undefined,
};
EOF
pnpm dev > /tmp/ballbox-dev-manual.log 2>&1 & echo $! > /tmp/ballbox-dev.pid
sleep 6
curl -sI http://127.0.0.1:3010/player/screen_001 | head -n 5
pnpm exec playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts --config=.codex/playwright.ops2.config.ts --workers=1


Error: Cannot find module './tests/e2e/support/playwright-global-cleanup.ts'
Require stack:
- /mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/config.js
- /mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/program.js
- /mnt/rpi/repos/ballbox/node_modules/.pnpm/@playwright+test@1.58.2/node_modules/@playwright/test/cli.js
    at Function.<anonymous> (node:internal/modules/cjs/loader:1383:15)
    at Function.resolveFilename [as _resolveFilename] (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/transform/transform.js:247:36)
    at Function.resolve (node:internal/modules/helpers:157:19)
    at resolveScript (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/config.js:263:18)
    at /mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/config.js:69:128
    at Array.map (<anonymous>)
    at new FullConfigInternal (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/config.js:69:117)
    at loadConfig (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/configLoader.js:121:22)
    at loadConfigFromFile (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/configLoader.js:331:10)
    at runTests (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/program.js:197:18)
    at i.<anonymous> (/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/program.js:70:7) {
  code: 'MODULE_NOT_FOUND',
  requireStack: [
    '/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/common/config.js',
    '/mnt/rpi/repos/ballbox/node_modules/.pnpm/playwright@1.58.2/node_modules/playwright/lib/program.js',
    '/mnt/rpi/repos/ballbox/node_modules/.pnpm/@playwright+test@1.58.2/node_modules/@playwright/test/cli.js'
  ]
}

Command exited with code 1


write

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  globalSetup: "../tests/e2e/support/playwright-global-cleanup.ts",
  globalTeardown: "../tests/e2e/support/playwright-global-cleanup.ts",
  testDir: "../tests/e2e",
  reporter: "list",
  timeout: 45_000,
  use: {
    baseURL: "http://127.0.0.1:3010",
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],
});


Successfully wrote 565 bytes to /mnt/rpi/repos/ballbox/.codex/playwright.ops2.config.ts


bash

cd /mnt/rpi/repos/ballbox && pnpm dev > /tmp/ballbox-dev-manual.log 2>&1 & echo $! > /tmp/ballbox-dev.pid && sleep 6 && curl -sI http://127.0.0.1:3010/player/screen_001 | head -n 5 && pnpm exec playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts --config=.codex/playwright.ops2.config.ts --workers=1


Running 4 tests using 1 worker

  ✘  1 [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline (4ms)
  ✘  2 [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects (4ms)
  ✘  3 [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline (5ms)
  ✘  4 [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects (6ms)

  1) [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline

    Error: browserType.launch: Executable doesn't exist at /home/sebas/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
    ╔═════════════════════════════════════════════════════════════════════════╗
    ║ Looks like Playwright Test or Playwright was just installed or updated. ║
    ║ Please run the following command to download new browsers:              ║
    ║                                                                         ║
    ║     pnpm exec playwright install                                        ║
    ║                                                                         ║
    ║ <3 Playwright Team                                                      ║
    ╚═════════════════════════════════════════════════════════════════════════╝

    attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/player-offline-lightweight-65f40-k-when-browser-goes-offline-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/player-offline-lightweight-65f40-k-when-browser-goes-offline-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  2) [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects

    Error: browserType.launch: Executable doesn't exist at /home/sebas/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
    ╔═════════════════════════════════════════════════════════════════════════╗
    ║ Looks like Playwright Test or Playwright was just installed or updated. ║
    ║ Please run the following command to download new browsers:              ║
    ║                                                                         ║
    ║     pnpm exec playwright install                                        ║
    ║                                                                         ║
    ║ <3 Playwright Team                                                      ║
    ╚═════════════════════════════════════════════════════════════════════════╝

    attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/player-runtime-side-effect-187f9-and-impression-side-effects-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/player-runtime-side-effect-187f9-and-impression-side-effects-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  3) [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline

    Error: browserType.launch: Executable doesn't exist at /home/sebas/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
    ╔═════════════════════════════════════════════════════════════════════════╗
    ║ Looks like Playwright Test or Playwright was just installed or updated. ║
    ║ Please run the following command to download new browsers:              ║
    ║                                                                         ║
    ║     pnpm exec playwright install                                        ║
    ║                                                                         ║
    ║ <3 Playwright Team                                                      ║
    ╚═════════════════════════════════════════════════════════════════════════╝

    attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/tvs-offline-screen-runtime-6072b-k-when-browser-goes-offline-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/tvs-offline-screen-runtime-6072b-k-when-browser-goes-offline-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  4) [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects

    Error: browserType.launch: Executable doesn't exist at /home/sebas/.cache/ms-playwright/chromium_headless_shell-1208/chrome-linux/headless_shell
    ╔═════════════════════════════════════════════════════════════════════════╗
    ║ Looks like Playwright Test or Playwright was just installed or updated. ║
    ║ Please run the following command to download new browsers:              ║
    ║                                                                         ║
    ║     pnpm exec playwright install                                        ║
    ║                                                                         ║
    ║ <3 Playwright Team                                                      ║
    ╚═════════════════════════════════════════════════════════════════════════╝

    attachment #1: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/tvs-runtime-side-effects-s-a14b9-and-impression-side-effects-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/tvs-runtime-side-effects-s-a14b9-and-impression-side-effects-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  4 failed
    [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline
    [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects
    [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline
    [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects

Command exited with code 1


cd /mnt/rpi/repos/ballbox && pnpm exec playwright install chromium


Removing unused browser at /home/sebas/.cache/ms-playwright/chromium-1217
Removing unused browser at /home/sebas/.cache/ms-playwright/chromium_headless_shell-1217
Downloading Chrome for Testing 145.0.7632.6 (playwright chromium v1208) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1208/chromium-linux-arm64.zip
|                                                                                |   0% of 179.6 MiB
|■■■■■■■■                                                                        |  10% of 179.6 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■                                                        |  30% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                                |  40% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                        |  50% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                |  60% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                        |  70% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                |  80% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■        |  90% of 179.6 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 179.6 MiB
Chrome for Testing 145.0.7632.6 (playwright chromium v1208) downloaded to /home/sebas/.cache/ms-playwright/chromium-1208
Downloading Chrome Headless Shell 145.0.7632.6 (playwright chromium-headless-shell v1208) from https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/1208/chromium-headless-shell-linux-arm64.zip
|                                                                                |   0% of 106.4 MiB
|■■■■■■■■                                                                        |  10% of 106.4 MiB
|■■■■■■■■■■■■■■■■                                                                |  20% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■                                                        |  30% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                                |  40% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                        |  50% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                                |  60% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                        |  70% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■                |  80% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■        |  90% of 106.4 MiB
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% of 106.4 MiB
Chrome Headless Shell 145.0.7632.6 (playwright chromium-headless-shell v1208) downloaded to /home/sebas/.cache/ms-playwright/chromium_headless_shell-1208


cd /mnt/rpi/repos/ballbox && if [ -f /tmp/ballbox-dev.pid ]; then kill $(cat /tmp/ballbox-dev.pid) >/dev/null 2>&1 || true; fi && pnpm dev > /tmp/ballbox-dev-manual.log 2>&1 & echo $! > /tmp/ballbox-dev.pid && sleep 6 && curl -sI http://127.0.0.1:3010/player/screen_001 | head -n 5 && pnpm exec playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts --config=.codex/playwright.ops2.config.ts --workers=1


Running 4 tests using 1 worker

  ✘  1 [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline (226ms)
  ✘  2 [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects (298ms)
  ✘  3 [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline (203ms)
  ✘  4 [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects (274ms)

  1) [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline

    Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3010/player/screen_001
    Call log:
      - navigating to "http://127.0.0.1:3010/player/screen_001", waiting until "load"

      10 |   });
      11 |
    > 12 |   await page.goto("/player/screen_001");
         |              ^
      13 |
      14 |   await expect(page.getByText("Shell: lightweight-html-js")).toBeVisible({ timeout: 20_000 });
      15 |   await expect(page.getByText("Playback: cached")).toBeVisible({ timeout: 20_000 });
        at /mnt/rpi/repos/ballbox/tests/e2e/player-offline.spec.ts:12:14

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/player-offline-lightweight-65f40-k-when-browser-goes-offline-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/player-offline-lightweight-65f40-k-when-browser-goes-offline-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/player-offline-lightweight-65f40-k-when-browser-goes-offline-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  2) [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects

    Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3010/player/screen_001
    Call log:
      - navigating to "http://127.0.0.1:3010/player/screen_001", waiting until "load"

      35 |   );
      36 |
    > 37 |   await page.goto("/player/screen_001");
         |              ^
      38 |
      39 |   const heartbeatResponse = await heartbeatResponsePromise;
      40 |   const impressionResponse = await impressionResponsePromise;
        at /mnt/rpi/repos/ballbox/tests/e2e/player-runtime-side-effects.spec.ts:37:14

    Error: page.waitForResponse: Test ended.

      28 |   const testStartedAt = new Date();
      29 |
    > 30 |   const heartbeatResponsePromise = page.waitForResponse(
         |                                         ^
      31 |     (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/heartbeat")
      32 |   );
      33 |   const impressionResponsePromise = page.waitForResponse(
        at /mnt/rpi/repos/ballbox/tests/e2e/player-runtime-side-effects.spec.ts:30:41

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/player-runtime-side-effect-187f9-and-impression-side-effects-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/player-runtime-side-effect-187f9-and-impression-side-effects-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/player-runtime-side-effect-187f9-and-impression-side-effects-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  3) [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline

    Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3010/player/screen_001
    Call log:
      - navigating to "http://127.0.0.1:3010/player/screen_001", waiting until "load"

      10 |   });
      11 |
    > 12 |   await page.goto("/player/screen_001");
         |              ^
      13 |
      14 |   await expect(page.getByText("Playback: cached")).toBeVisible({ timeout: 20_000 });
      15 |   await expect(page.getByText("Active source: blob")).toBeVisible();
        at /mnt/rpi/repos/ballbox/tests/e2e/tvs-offline.spec.ts:12:14

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/tvs-offline-screen-runtime-6072b-k-when-browser-goes-offline-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/tvs-offline-screen-runtime-6072b-k-when-browser-goes-offline-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/tvs-offline-screen-runtime-6072b-k-when-browser-goes-offline-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  4) [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects

    Error: page.goto: net::ERR_CONNECTION_REFUSED at http://127.0.0.1:3010/player/screen_001
    Call log:
      - navigating to "http://127.0.0.1:3010/player/screen_001", waiting until "load"

      35 |   );
      36 |
    > 37 |   await page.goto("/player/screen_001");
         |              ^
      38 |
      39 |   const heartbeatResponse = await heartbeatResponsePromise;
      40 |   const impressionResponse = await impressionResponsePromise;
        at /mnt/rpi/repos/ballbox/tests/e2e/tvs-runtime-side-effects.spec.ts:37:14

    Error: page.waitForResponse: Test ended.

      28 |   const testStartedAt = new Date();
      29 |
    > 30 |   const heartbeatResponsePromise = page.waitForResponse(
         |                                         ^
      31 |     (response) => response.request().method() === "POST" && response.url().endsWith("/api/tvs/heartbeat")
      32 |   );
      33 |   const impressionResponsePromise = page.waitForResponse(
        at /mnt/rpi/repos/ballbox/tests/e2e/tvs-runtime-side-effects.spec.ts:30:41

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/tvs-runtime-side-effects-s-a14b9-and-impression-side-effects-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/tvs-runtime-side-effects-s-a14b9-and-impression-side-effects-chromium/trace.zip
    Usage:

        pnpm exec playwright show-trace test-results/tvs-runtime-side-effects-s-a14b9-and-impression-side-effects-chromium/trace.zip

    ────────────────────────────────────────────────────────────────────────────────────────────────

  4 failed
    [chromium] › tests/e2e/player-offline.spec.ts:3:5 › lightweight player keeps cached playback when browser goes offline
    [chromium] › tests/e2e/player-runtime-side-effects.spec.ts:27:5 › lightweight player records heartbeat and impression side effects
    [chromium] › tests/e2e/tvs-offline.spec.ts:3:5 › screen runtime keeps cached playback when browser goes offline
    [chromium] › tests/e2e/tvs-runtime-side-effects.spec.ts:27:5 › screen runtime records heartbeat and impression side effects

Command exited with code 1


cat /tmp/ballbox-dev-manual.log && echo '---PID---' && cat /tmp/ballbox-dev.pid && ps -p $(cat /tmp/ballbox-dev.pid) -f || true && echo '---PORT---' && ss -ltnp | rg ':3010' || true


> my-v0-project@0.1.0 dev /mnt/rpi/repos/ballbox
> next dev --port 3010

▲ Next.js 16.2.1 (Turbopack)
- Local:         http://localhost:3010
- Network:       http://192.168.1.33:3010
- Environments: .env.local
✓ Ready in 468ms
---PID---
650562
UID          PID    PPID  C STIME TTY          TIME CMD
---PORT---


> my-v0-project@0.1.0 build /mnt/rpi/repos/ballbox
> node scripts/next-build-safe.mjs

[build] linux/arm64 detected: using webpack + SWC wasm fallback to avoid native next-swc crash
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Skipping creating a lockfile at /mnt/rpi/repos/ballbox/.next/lock because we're using WASM bindings
▲ Next.js 16.2.1 (webpack)
- Environments: .env.local, .env.production

  Creating an optimized production build ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
✓ Compiled successfully in 8.6s
  Skipping validation of types
  Finished TypeScript config validation in 11ms ...
  Collecting page data using 3 workers ...
  Generating static pages using 3 workers (0/52) ...
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
⚠ experimental.useWasmBinary is not an option for supported platform linux/arm64 and will be ignored.
  Generating static pages using 3 workers (13/52)
  Generating static pages using 3 workers (26/52)
  Generating static pages using 3 workers (39/52)
✓ Generating static pages using 3 workers (52/52) in 873ms
  Finalizing page optimization ...
  Collecting build traces ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /admin
├ ƒ /admin/login
├ ƒ /admin/network
├ ƒ /admin/payments
├ ƒ /admin/players
├ ƒ /admin/screens
├ ƒ /admin/sports
├ ƒ /admin/tvs
├ ƒ /api/account/session
├ ƒ /api/admin/logout
├ ƒ /api/admin/payments/operations
├ ƒ /api/admin/payments/sessions
├ ƒ /api/admin/payments/sessions/[sessionId]
├ ƒ /api/admin/players
├ ƒ /api/admin/players/[playerId]
├ ƒ /api/admin/players/[playerId]/memberships
├ ƒ /api/admin/players/memberships/[membershipId]
├ ƒ /api/admin/session
├ ƒ /api/admin/sports/classes
├ ƒ /api/admin/sports/classes/[classId]
├ ƒ /api/admin/sports/coaches
├ ƒ /api/admin/sports/coaches/[coachId]
├ ƒ /api/admin/sports/open-matches
├ ƒ /api/admin/sports/open-matches/[matchId]
├ ƒ /api/admin/sports/operations
├ ƒ /api/admin/summary
├ ƒ /api/admin/tvs/assignments/[assignmentId]
├ ƒ /api/admin/tvs/campaigns
├ ƒ /api/admin/tvs/campaigns/[campaignId]
├ ƒ /api/admin/tvs/campaigns/[campaignId]/assignments
├ ƒ /api/admin/tvs/campaigns/[campaignId]/creatives
├ ƒ /api/admin/tvs/creatives/[creativeId]
├ ƒ /api/admin/tvs/operations
├ ƒ /api/admin/tvs/screens
├ ƒ /api/admin/tvs/screens/[screenId]
├ ƒ /api/admin/tvs/tv-devices
├ ƒ /api/admin/tvs/tv-devices/[tvDeviceId]
├ ƒ /api/atc-public/availability/sportclubs/[sportclubId]
├ ƒ /api/classes/logout
├ ƒ /api/classes/offers
├ ƒ /api/classes/offers/[offerId]
├ ƒ /api/classes/requests
├ ƒ /api/classes/session
├ ƒ /api/clubs/accounts
├ ƒ /api/clubs/coaches
├ ƒ /api/clubs/coaches/[coachId]
├ ƒ /api/clubs/invites
├ ƒ /api/clubs/logout
├ ƒ /api/clubs/manual-classes
├ ƒ /api/clubs/me
├ ƒ /api/clubs/session
├ ƒ /api/clubs/venues
├ ƒ /api/clubs/venues/[venueId]
├ ƒ /api/coaches/accounts
├ ƒ /api/coaches/availability
├ ƒ /api/coaches/clubs
├ ƒ /api/coaches/invites/[token]
├ ƒ /api/coaches/logout
├ ƒ /api/coaches/me
├ ƒ /api/coaches/session
├ ƒ /api/coaches/students
├ ƒ /api/coaches/students/[bookingId]
├ ƒ /api/coaches/students/players/[playerId]
├ ƒ /api/integrations/payments/approved
├ ƒ /api/integrations/payments/health
├ ƒ /api/integrations/payments/orders
├ ƒ /api/integrations/payments/sessions
├ ƒ /api/network
├ ƒ /api/network/[clubId]
├ ƒ /api/network/[clubId]/venues
├ ƒ /api/network/[clubId]/venues/[venueId]
├ ƒ /api/network/venues/[venueId]/vending-machines
├ ƒ /api/network/venues/[venueId]/vending-machines/[vendingMachineId]
├ ƒ /api/players
├ ƒ /api/sports
├ ƒ /api/tvs/health
├ ƒ /api/tvs/heartbeat
├ ƒ /api/tvs/impression
├ ƒ /api/tvs/manifest/[screenId]
├ ƒ /api/tvs/screens
├ ƒ /classes
├ ƒ /classes-by-date
├ ƒ /classes/[offerId]
├ ƒ /classes/login
├ ƒ /classes/me
├ ƒ /clubs
├ ƒ /clubs/coaches
├ ƒ /clubs/venues/[venueId]
├ ○ /coaches
├ ƒ /coaches/settings
├ ƒ /notification/mercadopago
└ ƒ /player/[screenId]

ƒ Proxy (Middleware)

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand


cd /mnt/rpi/repos/ballbox && pnpm start > /tmp/ballbox-start.log 2>&1 & echo $! > /tmp/ballbox-start.pid && sleep 6 && cat /tmp/ballbox-start.log && echo '---' && curl -sI http://127.0.0.1:3000/player/screen_001 | head -n 5 && echo '---PORT---' && ss -ltnp | rg ':3000' || true


> my-v0-project@0.1.0 start /mnt/rpi/repos/ballbox
> next start

▲ Next.js 16.2.1
- Local:         http://localhost:3000
- Network:       http://192.168.1.33:3000
✓ Ready in 172ms
---
HTTP/1.1 200 OK
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
cache-control: no-store
content-type: text/html; charset=utf-8
Date: Sun, 19 Apr 2026 19:08:42 GMT
---PORT---
LISTEN 0      511                              *:3000             *:*    users:(("next-server (v1",pid=651311,fd=21))


write

import { defineConfig, devices } from "@playwright/test";

const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000";

export default defineConfig({
  globalSetup: "../tests/e2e/support/playwright-global-cleanup.ts",
  globalTeardown: "../tests/e2e/support/playwright-global-cleanup.ts",
  testDir: "../tests/e2e",
  reporter: "list",
  timeout: 45_000,
  use: {
    baseURL,
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
    video: "retain-on-failure",
  },
  projects: [
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
      },
    },
  ],
});


Successfully wrote 617 bytes to /mnt/rpi/repos/ballbox/.codex/playwright.ops2.config.ts


bash

cd /mnt/rpi/repos/ballbox && PLAYWRIGHT_BASE_URL=http://127.0.0.1:3000 pnpm exec playwright test tests/e2e/player-offline.spec.ts tests/e2e/player-runtime-side-effects.spec.ts tests/e2e/tvs-offline.spec.ts tests/e2e/tvs-runtime-side-effects.spec.ts --config=.codex/playwright.ops2.config.ts --workers=1