From a0e2cd8699277f57b2e65b1c05d30205facff655 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 9 Sep 2026 17:14:10 +0300 Subject: [PATCH 1/3] feat(tests): add E2E tests for real-streamer leave-navigation and workflow --- .../real-streamer-e2e-workflow.test.js | 139 ++++++++++++ __tests__/unit/scripts/run-leave-nav.test.js | 209 ++++++++++++++++++ .../plans/2026-09-09-real-streamer-ci-e2e.md | 108 +++++++++ e2e/run-leave-nav.js | 122 ++++++---- 4 files changed, 537 insertions(+), 41 deletions(-) create mode 100644 __tests__/unit/scripts/real-streamer-e2e-workflow.test.js create mode 100644 __tests__/unit/scripts/run-leave-nav.test.js create mode 100644 docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md diff --git a/__tests__/unit/scripts/real-streamer-e2e-workflow.test.js b/__tests__/unit/scripts/real-streamer-e2e-workflow.test.js new file mode 100644 index 00000000..00b8b96e --- /dev/null +++ b/__tests__/unit/scripts/real-streamer-e2e-workflow.test.js @@ -0,0 +1,139 @@ +/** + * @jest-environment node + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const YAML = require('yaml'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const WORKFLOW = path.join(REPO_ROOT, '.github/workflows/real-streamer-e2e.yml'); +const ANDROID_RUNNER = path.join(REPO_ROOT, 'e2e/run-android-ci.sh'); + +function workflow() { + return YAML.parse(fs.readFileSync(WORKFLOW, 'utf8')); +} + +function step(job, name) { + const found = job.steps.find((candidate) => candidate.name === name); + expect(found).toBeDefined(); + return found; +} + +describe('real-streamer E2E workflow', () => { + it('is dispatch-only and defaults to an immutable streamer commit', () => { + const config = workflow(); + expect(Object.keys(config.on)).toEqual(['workflow_dispatch']); + expect(config.on.workflow_dispatch.inputs.streamer_sha.default).toMatch(/^[0-9a-f]{40}$/); + }); + + it('builds the checked-out streamer demo target with BuildKit caching', () => { + const job = workflow().jobs['real-streamer-e2e']; + const checkout = step(job, 'Check out pinned streamer'); + expect(checkout.with).toMatchObject({ + repository: 'RonenMars/threadbase-streamer', + path: '.ci/threadbase-streamer', + ref: '${{ inputs.streamer_sha }}', + }); + + const build = step(job, 'Build deterministic streamer demo image'); + expect(build.uses).toMatch(/^docker\/build-push-action@/); + expect(build.with).toMatchObject({ + context: '.ci/threadbase-streamer', + file: '.ci/threadbase-streamer/docker/Dockerfile', + target: 'demo', + load: true, + 'cache-from': expect.stringContaining('type=gha'), + 'cache-to': expect.stringContaining('type=gha'), + }); + }); + + it('probes the real backend and records reproducibility metadata', () => { + const job = workflow().jobs['real-streamer-e2e']; + const start = step(job, 'Start and probe streamer'); + expect(start.run).toContain('127.0.0.1:8766:8080'); + expect(start.run).toContain('/healthz'); + expect(start.run).toContain('/api/info'); + expect(start.run).toContain('Authorization: Bearer'); + for (const field of ['Container ID', 'Streamer commit', 'Image ID', 'Health response']) { + expect(start.run).toContain(field); + } + expect(start.run).toContain('GITHUB_STEP_SUMMARY'); + }); + + it('passes split URLs and a container path to the Android matrix', () => { + const job = workflow().jobs['real-streamer-e2e']; + const emulator = step(job, 'Install APK and run real-streamer matrix on Android API 35'); + expect(emulator.env).toMatchObject({ + E2E_PLATFORM: 'android', + REAL_STREAMER_CONTROL_URL: 'http://127.0.0.1:8766', + REAL_STREAMER_APP_URL: 'http://10.0.2.2:8766', + REAL_STREAMER_SESSION_PATH: '/home/demo/projects/threadbase-mobile', + }); + expect(emulator.with.script).toBe('bash e2e/run-android-ci.sh'); + }); + + it('uploads both streamer and Maestro evidence on failure and always tears down exact resources', () => { + const job = workflow().jobs['real-streamer-e2e']; + const artifacts = step(job, 'Upload failure artifacts'); + expect(artifacts.if).toContain('failure()'); + expect(artifacts.with.path).toContain('e2e/_artifacts/streamer.log'); + expect(artifacts.with.path).toContain('e2e/_artifacts/debug'); + + const cleanup = step(job, 'Stop streamer'); + expect(cleanup.if).toContain('always()'); + expect(cleanup.run).toContain('docker rm --force "$CONTAINER_NAME"'); + expect(cleanup.run).toContain('docker volume rm "$VOLUME_NAME"'); + }); +}); + +describe('run-android-ci.sh real-streamer branch', () => { + it('runs the leave-navigation controller without starting the mock suite', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'real-streamer-android-')); + const bin = path.join(dir, 'bin'); + const apk = path.join(dir, 'app-release.apk'); + const nodeLog = path.join(dir, 'node.log'); + const npmLog = path.join(dir, 'npm.log'); + fs.mkdirSync(bin); + fs.writeFileSync(apk, 'apk'); + fs.writeFileSync( + path.join(bin, 'adb'), + `#!/bin/bash + case "$*" in + *"getprop sys.boot_completed"*) echo 1 ;; + esac + exit 0 + `, + { mode: 0o755 }, + ); + fs.writeFileSync(path.join(bin, 'node'), `#!/bin/bash\nprintf '%s\\n' "$*" >> "${nodeLog}"\n`, { + mode: 0o755, + }); + fs.writeFileSync(path.join(bin, 'npm'), `#!/bin/bash\nprintf '%s\\n' "$*" >> "${npmLog}"\n`, { + mode: 0o755, + }); + + const result = spawnSync('/bin/bash', [ANDROID_RUNNER], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${bin}:/usr/bin:/bin`, + E2E_RELEASE_APK: apk, + REAL_STREAMER_CONTROL_URL: 'http://127.0.0.1:8766', + }, + }); + + try { + expect(result.status).toBe(0); + expect(fs.readFileSync(nodeLog, 'utf8').trim()).toBe('e2e/run-leave-nav.js'); + expect(fs.existsSync(npmLog)).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/unit/scripts/run-leave-nav.test.js b/__tests__/unit/scripts/run-leave-nav.test.js new file mode 100644 index 00000000..bd6c7e15 --- /dev/null +++ b/__tests__/unit/scripts/run-leave-nav.test.js @@ -0,0 +1,209 @@ +/** + * @jest-environment node + * + * Black-box coverage for the real-streamer leave-navigation controller. The + * fixture is a separate process because the controller itself is also spawned; + * that keeps HTTP behavior real while the deterministic Maestro executable + * stands in for device automation. + */ + +'use strict'; + +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO_ROOT = path.resolve(__dirname, '../../..'); +const RUNNER = path.join(REPO_ROOT, 'e2e/run-leave-nav.js'); + +function waitForLine(child) { + return new Promise((resolve, reject) => { + let output = ''; + child.stdout.on('data', (chunk) => { + output += chunk.toString(); + const newline = output.indexOf('\n'); + if (newline !== -1) resolve(output.slice(0, newline)); + }); + child.once('error', reject); + child.once('exit', (code) => reject(new Error(`fixture exited before ready (${code})`))); + }); +} + +async function startFixture() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'leave-nav-fixture-')); + const serverPath = path.join(dir, 'server.js'); + fs.writeFileSync( + serverPath, + ` + const http = require('http'); + const sessions = new Map([ + ['pre-existing', { id: 'pre-existing', status: 'waiting_input', ptyAttached: true }], + ]); + const state = { starts: [], detailPolls: 0, stopped: [] }; + function json(res, status, value) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(value)); + } + const server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + const url = new URL(req.url, 'http://fixture'); + if (req.headers.authorization !== 'Bearer test-key' && !url.pathname.startsWith('/test/')) { + json(res, 401, { error: 'unauthorized' }); + return; + } + if (req.method === 'GET' && url.pathname === '/api/info') { + json(res, 200, { name: 'fixture-streamer' }); + return; + } + if (req.method === 'POST' && url.pathname === '/api/sessions/start') { + state.starts.push(JSON.parse(raw)); + sessions.set('pending-owned', { id: 'pending-owned', status: 'running', ptyAttached: false }); + json(res, 202, { id: 'pending-owned', status: 'pending' }); + return; + } + if (req.method === 'GET' && url.pathname === '/api/sessions/pending-owned') { + state.detailPolls += 1; + const session = sessions.get('pending-owned'); + if (state.detailPolls >= 2) Object.assign(session, { status: 'waiting_input', ptyAttached: true }); + json(res, 200, session); + return; + } + if (req.method === 'GET' && url.pathname === '/api/sessions') { + json(res, 200, [...sessions.values()]); + return; + } + const stop = /^\\/api\\/sessions\\/([^/]+)\\/stop$/.exec(url.pathname); + if (req.method === 'POST' && stop) { + const id = decodeURIComponent(stop[1]); + state.stopped.push(id); + const session = sessions.get(id); + if (session) Object.assign(session, { status: 'idle', ptyAttached: false }); + json(res, 200, { ok: true }); + return; + } + if (req.method === 'POST' && url.pathname === '/test/create') { + sessions.set('flow-owned', { id: 'flow-owned', status: 'waiting_input', ptyAttached: true }); + json(res, 201, { id: 'flow-owned' }); + return; + } + if (req.method === 'GET' && url.pathname === '/test/state') { + json(res, 200, state); + return; + } + json(res, 404, { error: 'not found' }); + }); + }); + server.listen(0, '127.0.0.1', () => console.log(server.address().port)); + process.on('SIGTERM', () => server.close(() => process.exit(0))); + `, + ); + + const child = spawn(process.execPath, [serverPath], { stdio: ['ignore', 'pipe', 'inherit'] }); + const port = await waitForLine(child); + return { + child, + dir, + url: `http://127.0.0.1:${port}`, + async state() { + return fetch(`http://127.0.0.1:${port}/test/state`).then((res) => res.json()); + }, + }; +} + +function createMaestroStub(dir) { + const stub = path.join(dir, 'maestro-stub.js'); + const log = path.join(dir, 'maestro-argv.jsonl'); + fs.writeFileSync( + stub, + `#!/usr/bin/env node + const fs = require('fs'); + const args = process.argv.slice(2); + fs.appendFileSync(process.env.MAESTRO_ARGV_LOG, JSON.stringify(args) + '\\n'); + if (args.includes('SESSION_MODE=new')) { + fetch(process.env.FIXTURE_URL + '/test/create', { method: 'POST' }) + .then((res) => { if (!res.ok) throw new Error(String(res.status)); }) + .then(() => process.exit(0), (err) => { console.error(err); process.exit(1); }); + } + `, + { mode: 0o755 }, + ); + return { stub, log }; +} + +function runRunner(args, env) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [RUNNER, ...args], { + cwd: REPO_ROOT, + env: { ...process.env, E2E_XCTEST_CRASH_GRACE_MS: '0', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('error', reject); + child.once('exit', (status) => resolve({ status, stdout, stderr })); + }); +} + +describe('run-leave-nav real-streamer controller', () => { + let fixture; + let maestro; + + beforeEach(async () => { + fixture = await startFixture(); + maestro = createMaestroStub(fixture.dir); + }); + + afterEach(async () => { + fixture.child.kill('SIGTERM'); + await new Promise((resolve) => fixture.child.once('exit', resolve)); + fs.rmSync(fixture.dir, { recursive: true, force: true }); + }); + + function environment(overrides = {}) { + return { + E2E_SERVER_TOKEN: 'test-key', + E2E_MOCK_SERVER_URL: fixture.url, + REAL_STREAMER_CONTROL_URL: fixture.url, + REAL_STREAMER_APP_URL: 'http://10.0.2.2:8766', + REAL_STREAMER_SESSION_PATH: '/home/demo/projects/threadbase-mobile', + REAL_STREAMER_READY_TIMEOUT_MS: '2000', + REAL_STREAMER_READY_POLL_MS: '10', + MAESTRO_BIN: maestro.stub, + MAESTRO_ARGV_LOG: maestro.log, + FIXTURE_URL: fixture.url, + ...overrides, + }; + } + + it('uses the control URL for HTTP and passes the app URL to Maestro', async () => { + const result = await runRunner(['new/leave'], environment()); + + expect(result.status).toBe(0); + const [argv] = fs.readFileSync(maestro.log, 'utf8').trim().split('\n').map(JSON.parse); + expect(argv).toContain('E2E_MOCK_SERVER_URL=http://10.0.2.2:8766'); + }); + + it('polls a 202 start at the explicit server path until the session is ready', async () => { + const result = await runRunner(['resumed/leave'], environment()); + + expect(result.status).toBe(0); + const state = await fixture.state(); + expect(state.starts).toEqual([ + { path: '/home/demo/projects/threadbase-mobile', projectName: 'threadbase-mobile' }, + ]); + expect(state.detailPolls).toBeGreaterThanOrEqual(2); + }); + + it('stops invocation-owned sessions without stopping a pre-existing live session', async () => { + const result = await runRunner(['new/leave'], environment()); + + expect(result.status).toBe(0); + const state = await fixture.state(); + expect(state.stopped).toEqual(['flow-owned']); + }); +}); diff --git a/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md b/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md new file mode 100644 index 00000000..1bebc1af --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md @@ -0,0 +1,108 @@ +# Real Streamer CI E2E Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Run the six-case leave-session navigation matrix against a commit-pinned deterministic `threadbase-streamer` demo container in an opt-in Android GitHub Actions job. + +**Architecture:** Make `e2e/run-leave-nav.js` distinguish the runner-visible control URL from the emulator-visible app URL, poll pending session starts, and clean up only session IDs created during the invocation. Reuse the existing Android APK/emulator runner and add a dispatch-only workflow that builds the streamer's `demo` target, probes it, records provenance, and always tears it down. + +**Tech Stack:** Node.js 24, Jest, Maestro, Android API 35, Docker Buildx, GitHub Actions. + +**Spec:** `docs/research/2026-09-09-real-streamer-ci-e2e.md` + +## Global Constraints + +- The provider process is the deterministic `/usr/local/bin/claude` stub and has no Anthropic credential. +- The default streamer source is pinned to commit `2177f5b634855ac9a33903d9ed780978c1aa8d30`. +- The runner calls `http://127.0.0.1:8766`; the Android app calls `http://10.0.2.2:8766`. +- CI sessions use `/home/demo/projects/threadbase-mobile`. +- Cleanup stops only sessions created by the current invocation. +- The initial workflow is opt-in through `workflow_dispatch`; it is not a required pull-request check. + +--- + +### Task 1: Ownership-safe, platform-neutral leave-navigation harness + +**Files:** +- Modify: `e2e/run-leave-nav.js` +- Create: `__tests__/unit/scripts/run-leave-nav.test.js` + +**Interfaces:** +- Consumes: `REAL_STREAMER_CONTROL_URL`, `REAL_STREAMER_APP_URL`, `REAL_STREAMER_SESSION_PATH`, and `E2E_SERVER_TOKEN`. +- Produces: `streamer(url, token, options)`, `runMatrix(options)`, and a CLI that passes the app URL to Maestro while using the control URL for HTTP requests. + +- [ ] **Step 1: Write failing behavioral tests** + +Cover separate HTTP/app URLs and explicit session paths, `202 { id, status: "pending" }` readiness polling, and preservation of a pre-existing live session while newly observed invocation sessions are stopped. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/run-leave-nav.test.js` + +Expected: FAIL because the runner does not export the testable interfaces, uses one URL, rejects 202 responses, and stops all live sessions. + +- [ ] **Step 3: Implement the minimal harness changes** + +Export the runner helpers behind `if (require.main === module)`, add bounded pending-session polling, snapshot session IDs around each flow, add only newly created IDs to an owned set, and stop only owned IDs in `finally`. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/run-leave-nav.test.js` + +Expected: PASS. + +### Task 2: Android real-streamer execution path and dispatch workflow + +**Files:** +- Modify: `e2e/run-android-ci.sh` +- Create: `.github/workflows/real-streamer-e2e.yml` +- Create: `__tests__/unit/scripts/real-streamer-e2e-workflow.test.js` + +**Interfaces:** +- Consumes: the Task 1 CLI environment contract and the existing prebuilt Release APK path. +- Produces: a dispatch-only Android job with a pinned streamer checkout, cached demo-image build, unique container/volume, authenticated readiness probes, provenance summary, failure artifacts, and `always()` cleanup. + +- [ ] **Step 1: Write failing workflow and runner behavior tests** + +Parse the workflow YAML and verify dispatch-only triggering, the 40-character default streamer SHA, `demo` target, loopback/emulator URL split, server-side path, authenticated probe, provenance summary, failure artifact upload, and unconditional teardown. Execute `e2e/run-android-ci.sh` with command stubs and verify the real-streamer environment selects `run-leave-nav.js` without starting the mock server. + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/real-streamer-e2e-workflow.test.js` + +Expected: FAIL because the workflow and real-streamer Android branch do not exist. + +- [ ] **Step 3: Implement the minimal workflow and runner branch** + +Reuse the existing Release APK cache/build and emulator setup, build the pinned streamer's `demo` target with Buildx GHA caching, start it on runner port 8766, wait for `/healthz` and authenticated `/api/info`, invoke the leave matrix inside the emulator runner, collect failure artifacts, and remove the exact container and volume in an `always()` step. + +- [ ] **Step 4: Run focused tests and verify GREEN** + +Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/real-streamer-e2e-workflow.test.js __tests__/unit/scripts/run-leave-nav.test.js` + +Expected: PASS. + +### Task 3: Documentation status and repository verification + +**Files:** +- Modify: `docs/research/2026-09-09-real-streamer-ci-e2e.md` + +**Interfaces:** +- Consumes: completed Tasks 1 and 2. +- Produces: an implementation-status note naming the opt-in workflow and pinned default compatibility revision. + +- [ ] **Step 1: Update the research status** + +Change `Proposed` to `Implemented (opt-in)` and add a short implementation note linking the workflow and runner. + +- [ ] **Step 2: Run all required checks** + +Run: `npm run lint`, `npm run typecheck`, `npm run test:ci`, and `npm run test:scripts`. + +Expected: all commands exit 0. + +- [ ] **Step 3: Inspect the final diff** + +Run: `/opt/homebrew/bin/git diff --check && /opt/homebrew/bin/git status --short && /opt/homebrew/bin/git diff --stat && /opt/homebrew/bin/git diff` + +Expected: only the plan, harness, tests, workflow, runner branch, and research status are changed; no generated artifacts are present. diff --git a/e2e/run-leave-nav.js b/e2e/run-leave-nav.js index c469f371..2fa8a6d5 100644 --- a/e2e/run-leave-nav.js +++ b/e2e/run-leave-nav.js @@ -7,8 +7,9 @@ // // Start the streamer first: cd ../tb-streamer && npm run dev:verbose // -// Overridable: E2E_MOCK_SERVER_URL (default http://localhost:8766) and -// E2E_SERVER_TOKEN (default: the api_key in ~/.threadbase/server.yaml). +// Overridable: REAL_STREAMER_CONTROL_URL (Node HTTP), REAL_STREAMER_APP_URL +// (paired inside the app), REAL_STREAMER_SESSION_PATH (path on the streamer), +// and E2E_SERVER_TOKEN (default: the api_key in ~/.threadbase/server.yaml). // // Args narrow the matrix: `node e2e/run-leave-nav.js kill` or `... new/kill`. @@ -20,9 +21,8 @@ const path = require('path') const OPTIONS = ['kill', 'leave', 'kill_on_idle'] const MODES = ['new', 'resumed'] const REPO_ROOT = path.join(__dirname, '..') -// Every session this script spawns is killed again at the end of its combo, so -// the project only has to be a real directory the streamer is allowed to open. -const SESSION_PATH = REPO_ROOT +const DEFAULT_READY_TIMEOUT_MS = 120_000 +const DEFAULT_READY_POLL_MS = 500 function streamerToken() { if (process.env.E2E_SERVER_TOKEN) return process.env.E2E_SERVER_TOKEN @@ -35,7 +35,10 @@ function streamerToken() { return match[1] } -function streamer(url, token) { +function streamer(url, token, options = {}) { + const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS + const readyPollMs = options.readyPollMs ?? DEFAULT_READY_POLL_MS + const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))) // The streamer closes some responses (the NDJSON stop stream) as soon as it // is done writing, which surfaces here as `fetch failed / write EPIPE`. That // is a transport artefact of a request that did its job, so it must not take @@ -61,25 +64,38 @@ function streamer(url, token) { } return { info: () => call('GET', '/api/info'), - // The 200 shape is `{ session }`; a slow spawn answers 202 `{ id, status: - // 'pending' }`, which is not usable as a row id — treat it as a failure - // rather than tapping a row that does not exist yet. - start: async () => { + start: async (sessionPath) => { const res = await request('POST', '/api/sessions/start', { - path: SESSION_PATH, - projectName: path.basename(SESSION_PATH), + path: sessionPath, + projectName: path.basename(sessionPath), }) if (!res.ok) throw new Error(`start failed: ${res.status} ${res.text.slice(0, 200)}`) const parsed = JSON.parse(res.text) - const id = parsed?.session?.id - if (!id) throw new Error(`start did not return a ready session: ${res.text.slice(0, 200)}`) - return id + if (parsed?.session?.id) return parsed.session.id + if (res.status !== 202 || !parsed?.id) { + throw new Error(`start did not return a session id: ${res.text.slice(0, 200)}`) + } + + const deadline = Date.now() + readyTimeoutMs + while (Date.now() < deadline) { + const pending = await call('GET', `/api/sessions/${encodeURIComponent(parsed.id)}`) + if (pending.ok) { + const session = JSON.parse(pending.text) + if (session.ptyAttached === true || session.status === 'waiting_input') return parsed.id + if (session.status === 'idle' || session.lifecycle === 'failed' || session.failureReason) { + throw new Error(`session ${parsed.id} failed while starting: ${pending.text.slice(0, 200)}`) + } + } + await sleep(readyPollMs) + } + throw new Error(`session ${parsed.id} did not become ready within ${readyTimeoutMs}ms`) }, stop: (id) => call('POST', `/api/sessions/${encodeURIComponent(id)}/stop`), - live: async () => { + sessions: async () => { const res = await call('GET', '/api/sessions') if (!res.ok) return [] - return JSON.parse(res.text).filter((s) => s.ptyAttached) + const parsed = JSON.parse(res.text) + return Array.isArray(parsed) ? parsed : parsed.sessions ?? [] }, } } @@ -94,19 +110,7 @@ function runFlow(env) { }).status } -async function main() { - const url = process.env.E2E_MOCK_SERVER_URL || 'http://localhost:8766' - const token = streamerToken() - const api = streamer(url, token) - - const probe = await api.info().catch((err) => ({ ok: false, status: err.message })) - if (!probe.ok) { - console.error(`Streamer at ${url} did not answer GET /api/info (${probe.status}).`) - console.error('Start it with `npm run dev:verbose` in tb-streamer.') - process.exit(1) - } - - const only = process.argv.slice(2) +async function runMatrix({ api, appUrl, token, sessionPath, only = [], run = runFlow }) { const combos = [] for (const mode of MODES) { for (const option of OPTIONS) { @@ -120,13 +124,18 @@ async function main() { const results = [] for (const combo of combos) { console.log(`\n=== leave_session_nav: ${combo.name} ===`) + const before = new Set((await api.sessions()).map((session) => session.id)) + const owned = new Set() let existingId = '' - if (combo.mode === 'resumed') existingId = await api.start() + if (combo.mode === 'resumed') { + existingId = await api.start(sessionPath) + owned.add(existingId) + } try { results.push({ ...combo, - code: runFlow({ - E2E_MOCK_SERVER_URL: url, + code: run({ + E2E_MOCK_SERVER_URL: appUrl, E2E_SERVER_TOKEN: token, LEAVE_OPTION: combo.option, SESSION_MODE: combo.mode, @@ -134,19 +143,50 @@ async function main() { }), }) } finally { - // "Leave it" and "Kill on idle" deliberately keep the PTY alive, and a - // failed flow can strand one at any point — so never let a combo hand - // the next one a machine full of live agents. - for (const session of await api.live()) await api.stop(session.id) + // A new-mode flow creates its session inside the app, so the controller + // learns that id by comparing the server's rows with the pre-flow + // snapshot. Pre-existing rows are never eligible for cleanup. + for (const session of await api.sessions()) { + if (!before.has(session.id)) owned.add(session.id) + } + for (const id of owned) await api.stop(id) } } + return results +} + +async function main() { + const legacyUrl = process.env.E2E_MOCK_SERVER_URL || 'http://localhost:8766' + const controlUrl = process.env.REAL_STREAMER_CONTROL_URL || legacyUrl + const appUrl = process.env.REAL_STREAMER_APP_URL || legacyUrl + const sessionPath = process.env.REAL_STREAMER_SESSION_PATH || REPO_ROOT + const token = streamerToken() + const api = streamer(controlUrl, token, { + readyTimeoutMs: Number(process.env.REAL_STREAMER_READY_TIMEOUT_MS) || DEFAULT_READY_TIMEOUT_MS, + readyPollMs: Number(process.env.REAL_STREAMER_READY_POLL_MS) || DEFAULT_READY_POLL_MS, + }) + + const probe = await api.info().catch((err) => ({ ok: false, status: err.message })) + if (!probe.ok) { + console.error(`Streamer at ${controlUrl} did not answer GET /api/info (${probe.status}).`) + console.error('Start it with `npm run dev:verbose` in tb-streamer.') + process.exit(1) + } + + const only = process.argv.slice(2) + const results = await runMatrix({ api, appUrl, token, sessionPath, only }) + console.log('\n=== summary ===') for (const r of results) console.log(`${r.code === 0 ? 'PASS' : 'FAIL'} ${r.name}`) process.exit(results.some((r) => r.code !== 0) ? 1 : 0) } -main().catch((err) => { - console.error(err) - process.exit(1) -}) +if (require.main === module) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} + +module.exports = { runMatrix, streamer } From 403133da6183b4fb7031d2a4412ed79eec825e6e Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 9 Sep 2026 19:41:12 +0300 Subject: [PATCH 2/3] test(e2e): add real-streamer CI workflow --- .github/workflows/real-streamer-e2e.yml | 227 ++++++++++++++++++ __tests__/unit/scripts/run-leave-nav.test.js | 12 + .../2026-09-09-real-streamer-ci-e2e.md | 7 +- .../plans/2026-09-09-real-streamer-ci-e2e.md | 22 +- e2e/run-android-ci.sh | 4 + e2e/run-leave-nav.js | 8 +- scripts/ci-script-test-shards.json | 2 + 7 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/real-streamer-e2e.yml diff --git a/.github/workflows/real-streamer-e2e.yml b/.github/workflows/real-streamer-e2e.yml new file mode 100644 index 00000000..0a2b4f39 --- /dev/null +++ b/.github/workflows/real-streamer-e2e.yml @@ -0,0 +1,227 @@ +name: Real Streamer E2E + +on: + workflow_dispatch: + inputs: + ref: + description: 'Mobile branch, tag, commit SHA, or PR number to test' + required: false + default: 'main' + type: string + streamer_sha: + description: 'Full threadbase-streamer commit SHA' + required: false + default: '2177f5b634855ac9a33903d9ed780978c1aa8d30' + type: string + +concurrency: + group: real-streamer-e2e-${{ inputs.ref }}-${{ inputs.streamer_sha }} + cancel-in-progress: false + +jobs: + real-streamer-e2e: + name: Real streamer E2E (Android) + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + CONTAINER_NAME: threadbase-streamer-e2e-${{ github.run_id }}-${{ github.run_attempt }} + VOLUME_NAME: threadbase-streamer-e2e-${{ github.run_id }}-${{ github.run_attempt }} + STREAMER_IMAGE: threadbase-streamer-e2e:${{ inputs.streamer_sha }} + E2E_SERVER_TOKEN: tb_public_demo_reviewer_key + steps: + - name: Resolve mobile ref + id: mobile-ref + env: + INPUT_REF: ${{ inputs.ref }} + run: | + set -euo pipefail + if ! printf '%s' "$INPUT_REF" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]*$'; then + echo "::error::Refusing ref '$INPUT_REF' — expected a branch, tag, SHA, or PR number." + exit 1 + fi + if printf '%s' "$INPUT_REF" | grep -Eq '^[0-9]+$'; then + RESOLVED="refs/pull/$INPUT_REF/head" + else + RESOLVED="$INPUT_REF" + fi + echo "ref=$RESOLVED" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v5 + with: + ref: ${{ steps.mobile-ref.outputs.ref }} + + - name: Record mobile SHA + id: mobile-head + run: echo "sha=$(/usr/bin/git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Validate streamer commit + env: + STREAMER_SHA: ${{ inputs.streamer_sha }} + run: | + if ! printf '%s' "$STREAMER_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + echo "::error::streamer_sha must be a full 40-character lowercase commit SHA." + exit 1 + fi + + - name: Check out pinned streamer + uses: actions/checkout@v5 + with: + repository: RonenMars/threadbase-streamer + path: .ci/threadbase-streamer + ref: ${{ inputs.streamer_sha }} + + - uses: docker/setup-buildx-action@v3 + + - name: Build deterministic streamer demo image + uses: docker/build-push-action@v6 + with: + context: .ci/threadbase-streamer + file: .ci/threadbase-streamer/docker/Dockerfile + target: demo + load: true + tags: ${{ env.STREAMER_IMAGE }} + cache-from: type=gha,scope=real-streamer-demo-${{ inputs.streamer_sha }} + cache-to: type=gha,mode=max,scope=real-streamer-demo-${{ inputs.streamer_sha }} + + - name: Start and probe streamer + env: + STREAMER_SHA: ${{ inputs.streamer_sha }} + run: | + set -euo pipefail + docker volume create "$VOLUME_NAME" + docker run --detach \ + --name "$CONTAINER_NAME" \ + --mount "type=volume,source=$VOLUME_NAME,target=/data" \ + --publish 127.0.0.1:8766:8080 \ + --env "DEMO_API_KEY=$E2E_SERVER_TOKEN" \ + --env THREADBASE_PUBLIC_URL=http://10.0.2.2:8766 \ + "$STREAMER_IMAGE" + + deadline=$((SECONDS + 300)) + until HEALTH_RESPONSE=$(curl -fsS http://127.0.0.1:8766/healthz); do + if [ "$SECONDS" -ge "$deadline" ]; then + echo "::error::Streamer did not become healthy within 300 seconds." + docker logs "$CONTAINER_NAME" || true + exit 1 + fi + if [ "$(docker inspect --format '{{.State.Running}}' "$CONTAINER_NAME")" != true ]; then + echo "::error::Streamer container exited before becoming healthy." + docker logs "$CONTAINER_NAME" || true + exit 1 + fi + sleep 2 + done + curl -fsS \ + -H "Authorization: Bearer $E2E_SERVER_TOKEN" \ + http://127.0.0.1:8766/api/info > /dev/null + + CONTAINER_ID=$(docker inspect --format '{{.Id}}' "$CONTAINER_NAME") + IMAGE_ID=$(docker image inspect --format '{{.Id}}' "$STREAMER_IMAGE") + { + echo '### Real streamer provenance' + echo "- Container ID: \`$CONTAINER_ID\`" + echo "- Streamer commit: \`$STREAMER_SHA\`" + echo "- Image ID: \`$IMAGE_ID\`" + echo "- Health response: \`$HEALTH_RESPONSE\`" + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/setup-node@v5 + with: + node-version: 24.15.0 + cache: npm + + - name: Install mobile dependencies + run: npm ci + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - uses: android-actions/setup-android@v4 + + - uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: false + + - name: Cache Android Release APK + id: apk-cache + uses: actions/cache@v5 + with: + path: android/app/build/outputs/apk/release/app-release.apk + key: real-streamer-e2e-apk-v1-${{ runner.os }}-x86_64-${{ steps.mobile-head.outputs.sha }} + + - name: Assemble Android Release APK + if: ${{ steps.apk-cache.outputs.cache-hit != 'true' }} + env: + REACT_NATIVE_ARCHITECTURES: x86_64 + TB_MOBILE_UPLOAD_KEYSTORE: ${{ github.workspace }}/android/app/debug.keystore + TB_MOBILE_UPLOAD_KEYSTORE_PASSWORD: android + TB_MOBILE_UPLOAD_KEY_ALIAS: androiddebugkey + TB_MOBILE_UPLOAD_KEY_PASSWORD: android + SENTRY_DISABLE_AUTO_UPLOAD: true + working-directory: android + run: ./gradlew :app:assembleRelease -PreactNativeArchitectures="${REACT_NATIVE_ARCHITECTURES:-x86_64}" + + - name: Install Maestro CLI + run: | + MAESTRO_VERSION=$(node -p "require('./e2e/maestro-version.json').version") + curl -fsSL "https://get.maestro.mobile.dev" | env MAESTRO_VERSION="$MAESTRO_VERSION" bash + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + INSTALLED_MAESTRO_VERSION=$(MAESTRO_CLI_NO_ANALYTICS=1 "$HOME/.maestro/bin/maestro" --version | tail -n 1) + if [ "$INSTALLED_MAESTRO_VERSION" != "$MAESTRO_VERSION" ]; then + echo "::error::Expected Maestro $MAESTRO_VERSION, installed $INSTALLED_MAESTRO_VERSION." + exit 1 + fi + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Install APK and run real-streamer matrix on Android API 35 + uses: reactivecircus/android-emulator-runner@v2 + env: + E2E_PLATFORM: android + E2E_ANDROID_API_LEVEL: '35' + REAL_STREAMER_CONTROL_URL: http://127.0.0.1:8766 + REAL_STREAMER_APP_URL: http://10.0.2.2:8766 + REAL_STREAMER_SESSION_PATH: /home/demo/projects/threadbase-mobile + TB_MOBILE_UPLOAD_KEYSTORE: ${{ github.workspace }}/android/app/debug.keystore + TB_MOBILE_UPLOAD_KEYSTORE_PASSWORD: android + TB_MOBILE_UPLOAD_KEY_ALIAS: androiddebugkey + TB_MOBILE_UPLOAD_KEY_PASSWORD: android + SENTRY_DISABLE_AUTO_UPLOAD: true + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + emulator-options: -no-window -noaudio -no-boot-anim -gpu swiftshader_indirect + disable-animations: true + script: bash e2e/run-android-ci.sh + + - name: Capture streamer logs + if: ${{ failure() }} + run: | + mkdir -p e2e/_artifacts + docker logs "$CONTAINER_NAME" > e2e/_artifacts/streamer.log 2>&1 || true + + - name: Upload failure artifacts + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: real-streamer-e2e-${{ github.run_id }}-${{ github.run_attempt }} + path: | + e2e/_artifacts/streamer.log + e2e/_artifacts/debug + e2e/_artifacts/fallback + e2e/_artifacts/maestro-session + if-no-files-found: warn + + - name: Stop streamer + if: ${{ always() }} + run: | + docker rm --force "$CONTAINER_NAME" 2>/dev/null || true + docker volume rm "$VOLUME_NAME" 2>/dev/null || true diff --git a/__tests__/unit/scripts/run-leave-nav.test.js b/__tests__/unit/scripts/run-leave-nav.test.js index bd6c7e15..f975bdb4 100644 --- a/__tests__/unit/scripts/run-leave-nav.test.js +++ b/__tests__/unit/scripts/run-leave-nav.test.js @@ -206,4 +206,16 @@ describe('run-leave-nav real-streamer controller', () => { const state = await fixture.state(); expect(state.stopped).toEqual(['flow-owned']); }); + + it('stops a pending session when readiness times out before Maestro runs', async () => { + const result = await runRunner( + ['resumed/leave'], + environment({ REAL_STREAMER_READY_TIMEOUT_MS: '1' }), + ); + + expect(result.status).toBe(1); + const state = await fixture.state(); + expect(state.stopped).toEqual(['pending-owned']); + expect(fs.existsSync(maestro.log)).toBe(false); + }); }); diff --git a/docs/research/2026-09-09-real-streamer-ci-e2e.md b/docs/research/2026-09-09-real-streamer-ci-e2e.md index 3a70d138..1bd09de1 100644 --- a/docs/research/2026-09-09-real-streamer-ci-e2e.md +++ b/docs/research/2026-09-09-real-streamer-ci-e2e.md @@ -1,11 +1,16 @@ # Real Streamer E2E in CI -**Status:** Proposed +**Status:** Implemented (opt-in) **Date:** 2026-09-09 **Scope:** Run the leave-session navigation matrix against a real `tb-streamer` backend in GitHub Actions without invoking a hosted AI model. +## Implementation + +The dispatch-only [Real Streamer E2E workflow](../../.github/workflows/real-streamer-e2e.yml) builds the deterministic streamer demo target at the default pinned compatibility commit `2177f5b634855ac9a33903d9ed780978c1aa8d30` and runs the Android matrix through [the ownership-safe controller](../../e2e/run-leave-nav.js). +The workflow accepts another full streamer commit SHA for deliberate compatibility probes, records the resolved container and image identities in the job summary, and does not run on pull requests or the weekly schedule. + ## Goal Exercise the mobile app, HTTP and WebSocket transports, streamer session lifecycle, PTY manager, and navigation behavior in one CI test. diff --git a/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md b/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md index 1bebc1af..64f475dd 100644 --- a/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md +++ b/docs/superpowers/plans/2026-09-09-real-streamer-ci-e2e.md @@ -31,21 +31,21 @@ - Consumes: `REAL_STREAMER_CONTROL_URL`, `REAL_STREAMER_APP_URL`, `REAL_STREAMER_SESSION_PATH`, and `E2E_SERVER_TOKEN`. - Produces: `streamer(url, token, options)`, `runMatrix(options)`, and a CLI that passes the app URL to Maestro while using the control URL for HTTP requests. -- [ ] **Step 1: Write failing behavioral tests** +- [x] **Step 1: Write failing behavioral tests** Cover separate HTTP/app URLs and explicit session paths, `202 { id, status: "pending" }` readiness polling, and preservation of a pre-existing live session while newly observed invocation sessions are stopped. -- [ ] **Step 2: Run the focused tests and verify RED** +- [x] **Step 2: Run the focused tests and verify RED** Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/run-leave-nav.test.js` Expected: FAIL because the runner does not export the testable interfaces, uses one URL, rejects 202 responses, and stops all live sessions. -- [ ] **Step 3: Implement the minimal harness changes** +- [x] **Step 3: Implement the minimal harness changes** Export the runner helpers behind `if (require.main === module)`, add bounded pending-session polling, snapshot session IDs around each flow, add only newly created IDs to an owned set, and stop only owned IDs in `finally`. -- [ ] **Step 4: Run focused tests and verify GREEN** +- [x] **Step 4: Run focused tests and verify GREEN** Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/run-leave-nav.test.js` @@ -62,21 +62,21 @@ Expected: PASS. - Consumes: the Task 1 CLI environment contract and the existing prebuilt Release APK path. - Produces: a dispatch-only Android job with a pinned streamer checkout, cached demo-image build, unique container/volume, authenticated readiness probes, provenance summary, failure artifacts, and `always()` cleanup. -- [ ] **Step 1: Write failing workflow and runner behavior tests** +- [x] **Step 1: Write failing workflow and runner behavior tests** Parse the workflow YAML and verify dispatch-only triggering, the 40-character default streamer SHA, `demo` target, loopback/emulator URL split, server-side path, authenticated probe, provenance summary, failure artifact upload, and unconditional teardown. Execute `e2e/run-android-ci.sh` with command stubs and verify the real-streamer environment selects `run-leave-nav.js` without starting the mock server. -- [ ] **Step 2: Run the focused tests and verify RED** +- [x] **Step 2: Run the focused tests and verify RED** Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/real-streamer-e2e-workflow.test.js` Expected: FAIL because the workflow and real-streamer Android branch do not exist. -- [ ] **Step 3: Implement the minimal workflow and runner branch** +- [x] **Step 3: Implement the minimal workflow and runner branch** Reuse the existing Release APK cache/build and emulator setup, build the pinned streamer's `demo` target with Buildx GHA caching, start it on runner port 8766, wait for `/healthz` and authenticated `/api/info`, invoke the leave matrix inside the emulator runner, collect failure artifacts, and remove the exact container and volume in an `always()` step. -- [ ] **Step 4: Run focused tests and verify GREEN** +- [x] **Step 4: Run focused tests and verify GREEN** Run: `npx jest --config jest.config.scripts.js --runInBand __tests__/unit/scripts/real-streamer-e2e-workflow.test.js __tests__/unit/scripts/run-leave-nav.test.js` @@ -91,17 +91,17 @@ Expected: PASS. - Consumes: completed Tasks 1 and 2. - Produces: an implementation-status note naming the opt-in workflow and pinned default compatibility revision. -- [ ] **Step 1: Update the research status** +- [x] **Step 1: Update the research status** Change `Proposed` to `Implemented (opt-in)` and add a short implementation note linking the workflow and runner. -- [ ] **Step 2: Run all required checks** +- [x] **Step 2: Run all required checks** Run: `npm run lint`, `npm run typecheck`, `npm run test:ci`, and `npm run test:scripts`. Expected: all commands exit 0. -- [ ] **Step 3: Inspect the final diff** +- [x] **Step 3: Inspect the final diff** Run: `/opt/homebrew/bin/git diff --check && /opt/homebrew/bin/git status --short && /opt/homebrew/bin/git diff --stat && /opt/homebrew/bin/git diff` diff --git a/e2e/run-android-ci.sh b/e2e/run-android-ci.sh index 7e08c11b..2b679713 100755 --- a/e2e/run-android-ci.sh +++ b/e2e/run-android-ci.sh @@ -107,6 +107,10 @@ for pkg in com.google.android.apps.nexuslauncher com.android.launcher3 com.googl adb shell pm disable-user --user 0 "$pkg" >/dev/null 2>&1 || \ adb shell am force-stop "$pkg" >/dev/null 2>&1 || true done +if [ -n "${REAL_STREAMER_CONTROL_URL:-}" ]; then + node e2e/run-leave-nav.js + exit 0 +fi if [ -z "${FLOWS:-}" ]; then npm run test:e2e:mock exit 0 diff --git a/e2e/run-leave-nav.js b/e2e/run-leave-nav.js index 2fa8a6d5..16c02ab7 100644 --- a/e2e/run-leave-nav.js +++ b/e2e/run-leave-nav.js @@ -127,11 +127,11 @@ async function runMatrix({ api, appUrl, token, sessionPath, only = [], run = run const before = new Set((await api.sessions()).map((session) => session.id)) const owned = new Set() let existingId = '' - if (combo.mode === 'resumed') { - existingId = await api.start(sessionPath) - owned.add(existingId) - } try { + if (combo.mode === 'resumed') { + existingId = await api.start(sessionPath) + owned.add(existingId) + } results.push({ ...combo, code: run({ diff --git a/scripts/ci-script-test-shards.json b/scripts/ci-script-test-shards.json index e22b6f0d..fa7813f2 100644 --- a/scripts/ci-script-test-shards.json +++ b/scripts/ci-script-test-shards.json @@ -39,7 +39,9 @@ "__tests__/unit/scripts/localized-permission-strings.test.js", "__tests__/unit/scripts/merge-jest-coverage.test.js", "__tests__/unit/scripts/native-strings-check.test.js", + "__tests__/unit/scripts/real-streamer-e2e-workflow.test.js", "__tests__/unit/scripts/reset-podfile-lock-path-noise.test.js", + "__tests__/unit/scripts/run-leave-nav.test.js", "__tests__/unit/scripts/withLiveActivityTarget.test.js" ] } From 2bdd0cdca2ee7a2cecb35a450a112913c24bdeb3 Mon Sep 17 00:00:00 2001 From: Ronen Mars Date: Wed, 9 Sep 2026 21:47:35 +0300 Subject: [PATCH 3/3] fix(sessions): expose live header selector --- .../components/LiveSessionsHeader.test.tsx | 11 ++++++-- .../sessions/LiveSessionsHeader.stories.tsx | 26 +++++++++++++++++++ components/sessions/LiveSessionsHeader.tsx | 8 ++++-- 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 components/sessions/LiveSessionsHeader.stories.tsx diff --git a/__tests__/integration/components/LiveSessionsHeader.test.tsx b/__tests__/integration/components/LiveSessionsHeader.test.tsx index 37ebeb60..c1ed1d8d 100644 --- a/__tests__/integration/components/LiveSessionsHeader.test.tsx +++ b/__tests__/integration/components/LiveSessionsHeader.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react-native' +import { fireEvent, render } from '@testing-library/react-native' import { LiveSessionsHeader } from '@/components/sessions/LiveSessionsHeader' describe('LiveSessionsHeader', () => { @@ -23,9 +23,16 @@ describe('LiveSessionsHeader', () => { }) it('exposes live-sessions-header when the block is collapsible', async () => { + const onToggle = jest.fn() const { getByTestId } = await render( - {}} />, + , ) + fireEvent.press(getByTestId('live-sessions-header')) + expect(onToggle).toHaveBeenCalledTimes(1) + }) + + it('exposes live-sessions-header when the block is not collapsible', async () => { + const { getByTestId } = await render() expect(getByTestId('live-sessions-header')).toBeTruthy() }) }) diff --git a/components/sessions/LiveSessionsHeader.stories.tsx b/components/sessions/LiveSessionsHeader.stories.tsx new file mode 100644 index 00000000..e2bf0e91 --- /dev/null +++ b/components/sessions/LiveSessionsHeader.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react-native-web-vite' +import { View } from 'react-native' +import { LiveSessionsHeader } from './LiveSessionsHeader' + +const meta: Meta = { + title: 'sessions/LiveSessionsHeader', + component: LiveSessionsHeader, + decorators: [ + (Story) => ( + + + + ), + ], +} + +export default meta +type Story = StoryObj + +export const Fixed: Story = { + args: { count: 1, hasLive: true }, +} + +export const Collapsible: Story = { + args: { count: 4, hasLive: true, collapsed: false, onToggle: () => {} }, +} diff --git a/components/sessions/LiveSessionsHeader.tsx b/components/sessions/LiveSessionsHeader.tsx index f0472afb..57d481d2 100644 --- a/components/sessions/LiveSessionsHeader.tsx +++ b/components/sessions/LiveSessionsHeader.tsx @@ -28,7 +28,12 @@ export function LiveSessionsHeader({ count, hasLive, collapsed, onToggle }: Prop const collapsible = onToggle !== undefined const inner = ( - + {collapsible && (