Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .github/workflows/real-streamer-e2e.yml
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions __tests__/integration/components/LiveSessionsHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(
<LiveSessionsHeader count={4} hasLive collapsed onToggle={() => {}} />,
<LiveSessionsHeader count={4} hasLive collapsed onToggle={onToggle} />,
)
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(<LiveSessionsHeader count={1} hasLive />)
expect(getByTestId('live-sessions-header')).toBeTruthy()
})
})
139 changes: 139 additions & 0 deletions __tests__/unit/scripts/real-streamer-e2e-workflow.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading
Loading