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
4 changes: 4 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ ANTHROPIC_API_KEY=sk-ant-...
# Local development mode - skips Cloudflare Access auth and bypasses device pairing
# DEV_MODE=true

# E2E test mode - skips Cloudflare Access auth but keeps device pairing enabled
# Use this for automated tests that need to test the real pairing flow
# E2E_TEST_MODE=true

# Enable debug routes at /debug/* (optional)
# DEBUG_ROUTES=true

Expand Down
125 changes: 124 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

jobs:
test:
unit:
runs-on: ubuntu-latest

steps:
Expand All @@ -27,3 +28,125 @@ jobs:

- name: Run tests
run: npm test

e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
pull-requests: write

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Install Playwright
run: npx playwright install --with-deps chromium

- name: Install playwright-cli
run: npm install -g @playwright/cli

- name: Install cctr
uses: taiki-e/install-action@v2
with:
tool: cctr

- name: Run E2E tests
id: e2e
continue-on-error: true
env:
AI_GATEWAY_API_KEY: ${{ secrets.AI_GATEWAY_API_KEY }}
AI_GATEWAY_BASE_URL: ${{ secrets.AI_GATEWAY_BASE_URL }}
run: cctr -vv test/e2e

- name: Convert video and generate thumbnail
id: convert
if: always()
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg imagemagick bc
if ls /tmp/moltworker-e2e-videos/*.webm 1>/dev/null 2>&1; then
for webm in /tmp/moltworker-e2e-videos/*.webm; do
mp4="${webm%.webm}.mp4"
thumb="${webm%.webm}.png"

# Convert to mp4
ffmpeg -y -i "$webm" -c:v libx264 -preset fast -crf 22 -c:a aac "$mp4"

# Extract middle frame as thumbnail
duration=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$mp4")
midpoint=$(echo "$duration / 2" | bc -l)
ffmpeg -y -ss "$midpoint" -i "$mp4" -vframes 1 -update 1 -q:v 2 "$thumb"

# Add play button overlay using ImageMagick
width=$(identify -format '%w' "$thumb")
height=$(identify -format '%h' "$thumb")
cx=$((width / 2))
cy=$((height / 2))
convert "$thumb" \
-fill 'rgba(0,0,0,0.6)' -draw "circle ${cx},${cy} $((cx+50)),${cy}" \
-fill 'white' -draw "polygon $((cx-15)),$((cy-25)) $((cx-15)),$((cy+25)) $((cx+30)),${cy}" \
"$thumb"

echo "video_path=$mp4" >> $GITHUB_OUTPUT
echo "video_name=$(basename $mp4)" >> $GITHUB_OUTPUT
echo "thumb_path=$thumb" >> $GITHUB_OUTPUT
echo "thumb_name=$(basename $thumb)" >> $GITHUB_OUTPUT
done
echo "has_video=true" >> $GITHUB_OUTPUT
else
echo "has_video=false" >> $GITHUB_OUTPUT
fi

- name: Prepare video for upload
id: prepare
if: always() && steps.convert.outputs.has_video == 'true'
run: |
mkdir -p /tmp/e2e-video-upload/videos/${{ github.run_id }}
cp "${{ steps.convert.outputs.video_path }}" /tmp/e2e-video-upload/videos/${{ github.run_id }}/
cp "${{ steps.convert.outputs.thumb_path }}" /tmp/e2e-video-upload/videos/${{ github.run_id }}/
echo "video_url=https://github.com/${{ github.repository }}/raw/e2e-artifacts/videos/${{ github.run_id }}/${{ steps.convert.outputs.video_name }}" >> $GITHUB_OUTPUT
echo "thumb_url=https://github.com/${{ github.repository }}/raw/e2e-artifacts/videos/${{ github.run_id }}/${{ steps.convert.outputs.thumb_name }}" >> $GITHUB_OUTPUT

- name: Upload video to e2e-artifacts branch
if: always() && steps.convert.outputs.has_video == 'true'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: /tmp/e2e-video-upload
publish_branch: e2e-artifacts
keep_files: true

- name: Comment on PR with video
if: always() && github.event_name == 'pull_request' && steps.prepare.outputs.video_url
uses: peter-evans/create-or-update-comment@v4
with:
issue-number: ${{ github.event.pull_request.number }}
body: |
## E2E Test Recording

${{ steps.e2e.outcome == 'success' && '✅ Tests passed' || '❌ Tests failed' }}

[![E2E Test Video](${{ steps.prepare.outputs.thumb_url }})](${{ steps.prepare.outputs.video_url }})

- name: Add video link to summary
if: always()
run: |
echo "## E2E Test Recording" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.convert.outputs.has_video }}" == "true" ]; then
echo "📹 [Download video](${{ steps.prepare.outputs.video_url }})" >> $GITHUB_STEP_SUMMARY
else
echo "⚠️ No video recording found" >> $GITHUB_STEP_SUMMARY
fi

- name: Fail if E2E tests failed
if: steps.e2e.outcome == 'failure'
run: exit 1
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,12 @@ Thumbs.db

# Docker build artifacts
*.tar

# Veta agent memory
.veta/

# greger.el conversation
*.greger

# playwright-cli
.playwright-cli/
35 changes: 34 additions & 1 deletion src/auth/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isDevMode, extractJWT } from './middleware';
import { isDevMode, isE2ETestMode, extractJWT } from './middleware';
import type { MoltbotEnv } from '../types';
import type { Context } from 'hono';
import type { AppEnv } from '../types';
Expand Down Expand Up @@ -32,6 +32,28 @@ describe('isDevMode', () => {
});
});

describe('isE2ETestMode', () => {
it('returns true when E2E_TEST_MODE is "true"', () => {
const env = createMockEnv({ E2E_TEST_MODE: 'true' });
expect(isE2ETestMode(env)).toBe(true);
});

it('returns false when E2E_TEST_MODE is undefined', () => {
const env = createMockEnv();
expect(isE2ETestMode(env)).toBe(false);
});

it('returns false when E2E_TEST_MODE is "false"', () => {
const env = createMockEnv({ E2E_TEST_MODE: 'false' });
expect(isE2ETestMode(env)).toBe(false);
});

it('returns false when E2E_TEST_MODE is any other value', () => {
const env = createMockEnv({ E2E_TEST_MODE: 'yes' });
expect(isE2ETestMode(env)).toBe(false);
});
});

describe('extractJWT', () => {
// Helper to create a mock context
function createMockContext(options: {
Expand Down Expand Up @@ -158,6 +180,17 @@ describe('createAccessMiddleware', () => {
expect(setMock).toHaveBeenCalledWith('accessUser', { email: 'dev@localhost', name: 'Dev User' });
});

it('skips auth and sets dev user when E2E_TEST_MODE is true', async () => {
const { c, setMock } = createFullMockContext({ env: { E2E_TEST_MODE: 'true' } });
const middleware = createAccessMiddleware({ type: 'json' });
const next = vi.fn();

await middleware(c, next);

expect(next).toHaveBeenCalled();
expect(setMock).toHaveBeenCalledWith('accessUser', { email: 'dev@localhost', name: 'Dev User' });
});

it('returns 500 JSON error when CF Access not configured', async () => {
const { c, jsonMock } = createFullMockContext({ env: {} });
const middleware = createAccessMiddleware({ type: 'json' });
Expand Down
13 changes: 10 additions & 3 deletions src/auth/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ export interface AccessMiddlewareOptions {
}

/**
* Check if running in development mode (skips CF Access auth)
* Check if running in development mode (skips CF Access auth + device pairing)
*/
export function isDevMode(env: MoltbotEnv): boolean {
return env.DEV_MODE === 'true';
}

/**
* Check if running in E2E test mode (skips CF Access auth but keeps device pairing)
*/
export function isE2ETestMode(env: MoltbotEnv): boolean {
return env.E2E_TEST_MODE === 'true';
}

/**
* Extract JWT from request headers or cookies
*/
Expand All @@ -42,8 +49,8 @@ export function createAccessMiddleware(options: AccessMiddlewareOptions) {
const { type, redirectOnMissing = false } = options;

return async (c: Context<AppEnv>, next: Next) => {
// Skip auth in dev mode
if (isDevMode(c.env)) {
// Skip auth in dev mode or E2E test mode
if (isDevMode(c.env) || isE2ETestMode(c.env)) {
c.set('accessUser', { email: 'dev@localhost', name: 'Dev User' });
return next();
}
Expand Down
Loading