Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -380,8 +380,66 @@ jobs:
run: pnpm typecheck

- name: Test
env:
# The "Verify the SystemConfig repair path" step ends with a fresh
# `migrate deploy`, so this database has every table the gated
# real-Postgres tests need.
OUTPOST_TEST_DATABASE_URL: 'postgresql://ci:ci@127.0.0.1:5432/ci'
# Fail, never skip, the *-db tests if the URL above is ever lost.
OUTPOST_REQUIRE_DB_TESTS: '1'
run: pnpm test

# The Test step passes when a *-db file skips. Both variables above reach
# vitest only through turbo.json's `test.env` list (strict env mode strips
# everything else). If that list loses them, or a package-scoped
# `@copilotkit/outpost-web#test` task replaces the base one without it, every
# *-db file reports "skipped" and the run still exits 0.
#
# This step reruns those files through the same turbo task with a JSON
# reporter, then fails unless each file ran at least one test and every test
# passed. The check reads the report, not the environment, so it does not
# depend on the passthrough it is checking. --force stops a cached replay;
# a replay writes no report, so it would fail here rather than pass.
- name: Verify the real-Postgres tests ran
env:
OUTPOST_TEST_DATABASE_URL: 'postgresql://ci:ci@127.0.0.1:5432/ci'
OUTPOST_REQUIRE_DB_TESTS: '1'
run: |
set -euo pipefail
shopt -s nullglob
files=(apps/web/src/__tests__/*-db.test.ts)
if [ "${#files[@]}" -eq 0 ]; then
echo "FATAL: no apps/web/src/__tests__/*-db.test.ts files found."
exit 1
fi
report="$RUNNER_TEMP/db-tests.json"
rm -f "$report"
pnpm exec turbo run test --filter=@copilotkit/outpost-web --force -- \
--reporter=default --reporter=json --outputFile.json="$report" \
"${files[@]#apps/web/}"
if [ ! -s "$report" ]; then
echo "FATAL: vitest wrote no JSON report to $report."
exit 1
fi
failed=0
for f in "${files[@]}"; do
# A command substitution, so `set -e` stops the step if jq fails.
counts=$(jq -r --arg f "/$f" \
'[.testResults[] | select(.name | endswith($f)) | .assertionResults[].status]
| "\(length) \(map(select(. == "passed")) | length)"' "$report")
read -r total passed <<<"$counts"
if [ "$total" -eq 0 ] || [ "$passed" -ne "$total" ]; then
echo "FATAL: $f: $passed of $total tests passed; the rest were skipped or did not run."
failed=1
else
echo " $f: $passed/$total passed"
fi
done
if [ "$failed" -ne 0 ]; then
echo "The real-Postgres tests did not run. Check turbo.json's test.env list."
exit 1
fi

# Railway auto-deploys on push to main via GitHub integration.
# No explicit deploy step needed — Railway watches the repo directly.
#
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,7 @@ yarn.lock

# Local AI review pipeline session state (create-review / review-changes)
.chalk/

# Playwright output (apps/web/e2e)
apps/web/test-results/
apps/web/playwright-report/
44 changes: 44 additions & 0 deletions apps/web/e2e/docker-compose.e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Used only by apps/web/e2e (Playwright globalSetup), as Compose project outpost-e2e.
# Standalone on purpose: it is not layered on ../../../docker-compose.yml, sets no
# container_name, and publishes its own host ports (web 3100, Mailpit API 18025), so an
# e2e run never collides with or stops a developer's outpost-postgres / outpost-web.
# Runs a pre-built image, so the same spec can be pointed at an image built from main
# (RED) or from a branch (GREEN).
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: outpost
POSTGRES_PASSWORD: outpost
POSTGRES_DB: outpost
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U outpost']
interval: 5s
timeout: 5s
retries: 5

mailpit:
image: axllent/mailpit:latest
ports:
- '18025:8025'

web:
image: ${OUTPOST_E2E_IMAGE:?set OUTPOST_E2E_IMAGE to a locally built apps/web image}
pull_policy: never
ports:
- '3100:3000'
environment:
DATABASE_URL: postgresql://outpost:outpost@postgres:5432/outpost
NEXTAUTH_SECRET: e2e-only-secret
NEXTAUTH_URL: http://localhost:3100
AUTH_PROVIDER: credentials
NEXT_PUBLIC_AUTH_PROVIDER: credentials
NODE_ENV: production
PORT: 3000
SMTP_HOST: mailpit
SMTP_PORT: 1025
depends_on:
postgres:
condition: service_healthy
mailpit:
condition: service_started
102 changes: 102 additions & 0 deletions apps/web/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { execFileSync } from 'node:child_process';
import { request } from '@playwright/test';
import {
ADMIN,
BASE_URL,
MEMBER_PASSWORD,
ORG_NAME,
PENDING_INVITE,
USERS,
composeArgs,
} from './support';

async function expectOk(
res: { ok(): boolean; status(): number; text(): Promise<string> },
what: string,
) {
if (!res.ok()) throw new Error(`${what} failed: ${res.status()} ${await res.text()}`);
}

export default async function globalSetup(): Promise<void> {
if (!process.env.OUTPOST_E2E_IMAGE) {
throw new Error(
'Set OUTPOST_E2E_IMAGE to a locally built apps/web image tag (see e2e/support.ts).',
);
}
execFileSync('docker', [...composeArgs, 'down', '-v', '--remove-orphans'], {
stdio: 'inherit',
});
execFileSync(
'docker',
[
...composeArgs,
'up',
'-d',
'--no-build',
'--wait',
'--wait-timeout',
'180',
'postgres',
'mailpit',
'web',
],
{ stdio: 'inherit' },
);

const api = await request.newContext({ baseURL: BASE_URL });

await expectOk(
await api.post('/api/setup', {
data: {
orgName: ORG_NAME,
orgEmail: 'org@e2e.test',
name: 'E2E Admin',
email: ADMIN.email,
password: ADMIN.password,
confirmPassword: ADMIN.password,
},
}),
'setup',
);

// Sign the admin in through NextAuth's own credentials endpoint.
const { csrfToken } = (await (await api.get('/api/auth/csrf')).json()) as { csrfToken: string };
await expectOk(
await api.post('/api/auth/callback/credentials', {
form: {
csrfToken,
email: ADMIN.email,
password: ADMIN.password,
json: 'true',
callbackUrl: `${BASE_URL}/dashboard`,
},
}),
'admin sign-in',
);
await api.get('/dashboard'); // the middleware sets the csrf cookie on this response
const csrf = (await api.storageState()).cookies.find((c) => c.name === 'csrf')?.value;
if (!csrf) throw new Error('No csrf cookie after loading /dashboard');

for (const email of [...Object.values(USERS), PENDING_INVITE]) {
const invite = await api.post('/api/team/invite', {
headers: { 'X-CSRF-Token': csrf },
data: { email, role: 'MEMBER' },
});
await expectOk(invite, `invite ${email}`);
if (email === PENDING_INVITE) continue;
const { inviteUrl } = (await invite.json()) as { inviteUrl: string };
const token = new URL(inviteUrl).searchParams.get('token');
await expectOk(
await api.post('/api/team/invite/accept', {
data: {
token,
name: email.split('@')[0],
password: MEMBER_PASSWORD,
confirmPassword: MEMBER_PASSWORD,
},
}),
`accept ${email}`,
);
}
await api.dispose();
}
9 changes: 9 additions & 0 deletions apps/web/e2e/global-teardown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { execFileSync } from 'node:child_process';
import { composeArgs } from './support';

export default async function globalTeardown(): Promise<void> {
if (process.env.E2E_KEEP === '1') return;
execFileSync('docker', [...composeArgs, 'down', '-v', '--remove-orphans'], {
stdio: 'inherit',
});
}
Loading
Loading