Skip to content

Commit 724ff00

Browse files
committed
Add the live Platform suite against the real stack
1 parent fb8d97f commit 724ff00

18 files changed

Lines changed: 962 additions & 152 deletions

‎.github/scripts/http-e2e.sh‎

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# CI groups, which reuse the production build from `e2e-build`.
44
# The job extracts it into apps/sim/.next before running these suites.
55
#
6-
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|assistant-actions|organization-search|browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access|project-files|mobile|seo> (run from apps/sim)
6+
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|assistant-actions|organization-search|browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access|project-files|mobile|seo|platform-live-zone|platform-live-flag-off> (run from apps/sim)
77
#
88
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
99
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
@@ -20,7 +20,7 @@
2020
# telemetry flush runs detached and still writes .next/dev).
2121
set -euo pipefail
2222

23-
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|assistant-actions|organization-search|browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access|project-files|mobile|seo>}
23+
group=${1:?usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|assistant-actions|organization-search|browser-extension|browser-extension-native|browser-extension-navigation|browser-extension-composer|browser-extension-access|project-files|mobile|seo|platform-live-zone|platform-live-flag-off>}
2424
report_dir="$RUNNER_TEMP/e2e"
2525
ready_timeout_seconds=600
2626
mkdir -p "$report_dir"
@@ -32,6 +32,10 @@ status_log=''
3232
realtime_pid=''
3333
realtime_tag=''
3434
realtime_log=''
35+
platform_pid=''
36+
platform_tag=''
37+
platform_log=''
38+
agent_pid=''
3539
browsers_pid=''
3640
local_app_url=''
3741
# `dev` or `start`; a group sets it before start_app.
@@ -63,6 +67,17 @@ finish() {
6367
tail -n 100 "$realtime_log"
6468
fi
6569
fi
70+
if [ -n "$platform_pid" ]; then
71+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$platform_pid" "$platform_tag" || status=1
72+
wait "$platform_pid" 2>/dev/null || true
73+
if [ "$status" -ne 0 ]; then
74+
tail -n 100 "$platform_log"
75+
fi
76+
fi
77+
if [ -n "$agent_pid" ]; then
78+
kill "$agent_pid" 2>/dev/null || true
79+
wait "$agent_pid" 2>/dev/null || true
80+
fi
6681
if [ -n "$browsers_pid" ]; then
6782
kill -- "-$browsers_pid" 2>/dev/null || true
6883
wait "$browsers_pid" 2>/dev/null || true
@@ -133,6 +148,73 @@ start_realtime() {
133148
wait_ready "$realtime_pid" "http://127.0.0.1:$port/health" "$name realtime server"
134149
}
135150

151+
# stop_app: stops the app start_app started, so a group can start it again under other flags.
152+
stop_app() {
153+
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag"
154+
wait "$server_pid" 2>/dev/null || true
155+
server_pid=''
156+
}
157+
158+
# start_platform <port>: the production build of apps/sim/platform, behind the main app's rewrite.
159+
start_platform() {
160+
local port=$1
161+
platform_tag="platform-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-$$"
162+
platform_log="$report_dir/platform-live-platform.log"
163+
E2E_APP="$platform_tag" SIM_PLATFORM_API_ORIGIN="$local_app_url" setsid node \
164+
../../node_modules/next/dist/bin/next start platform --hostname 127.0.0.1 --port "$port" \
165+
> "$platform_log" 2>&1 &
166+
platform_pid=$!
167+
wait_ready "$platform_pid" "http://127.0.0.1:$port/platform" 'Platform zone'
168+
}
169+
170+
# platform_live_environment: one hosted Enterprise organization, the deterministic seed its specs
171+
# read, and a scripted worker in place of the model. Hosted is decided by the app's hostname, which
172+
# the job maps to loopback. Google and Microsoft answer only to real credentials, so those clients
173+
# are fixtures, and what no fixture can reach is reported by the specs as a skip.
174+
platform_live_environment() {
175+
app_server=start
176+
export REDIS_URL=redis://127.0.0.1:6379
177+
export E2E_APP_PUBLIC_URL=http://platform-live.sim.ai:3027
178+
export INTERNAL_API_BASE_URL=http://127.0.0.1:3027 TRUSTED_ORIGINS=http://127.0.0.1:3027
179+
export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true
180+
export ENTERPRISE_ENABLED=true NEXT_PUBLIC_ENTERPRISE_ENABLED=true
181+
export SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true
182+
export SCIM_ENABLED=true NEXT_PUBLIC_SCIM_ENABLED=true
183+
export ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
184+
export KNOWLEDGE_MEMBER_ACCESS=true CREDENTIAL_GROUPS=true ACCESS_CONTROL_ENABLED=true
185+
export NEXT_PUBLIC_ACCESS_CONTROL_ENABLED=true
186+
export SIM_LINKS=true PEOPLE=true
187+
export INTERNAL_API_SECRET=platform-live-ci-local-secret-at-least-32-characters
188+
export COPILOT_API_KEY=platform-live-ci-local-copilot-key MOTHERSHIP_SIM_TRANSPORT=direct
189+
export SIM_AGENT_API_URL=http://127.0.0.1:3029
190+
export GOOGLE_CLIENT_ID=platform-live-google-client GOOGLE_CLIENT_SECRET=platform-live-google-secret
191+
export MICROSOFT_CLIENT_ID=platform-live-microsoft-client MICROSOFT_CLIENT_SECRET=platform-live-microsoft-secret
192+
export STRIPE_SECRET_KEY=sk_test_platform_live STRIPE_WEBHOOK_SECRET=whsec_platform_live
193+
194+
export PLATFORM_LIVE_ORIGIN="$E2E_APP_PUBLIC_URL"
195+
export PLATFORM_LIVE_PASSWORD=platform-live-password-1
196+
export PLATFORM_LIVE_ORGANIZATION_ID=platform-live-organization
197+
export PLATFORM_LIVE_ORGANIZATION_NAME='Sim Live Org'
198+
export PLATFORM_LIVE_OWNER_EMAIL=olive@platform-live.test
199+
export PLATFORM_LIVE_EMAIL=ada@platform-live.test
200+
export PLATFORM_LIVE_MEMBER_EMAIL=mia@platform-live.test
201+
export PLATFORM_LIVE_CONNECTED_EMAIL=cory@platform-live.test
202+
export PLATFORM_LIVE_ROLE_TARGET_EMAIL=rae@platform-live.test
203+
export PLATFORM_LIVE_REMOVABLE_EMAIL=remy@platform-live.test
204+
export PLATFORM_LIVE_NO_DIRECTORY_EMAIL=noor@platform-live.test
205+
export PLATFORM_LIVE_REFUSED_INVITE_EMAIL=otto@platform-live.test
206+
export PLATFORM_LIVE_ORCHESTRATOR=1
207+
bun --no-env-file scripts/platform-live-seed.ts
208+
}
209+
210+
# run_platform_live <project> <report-name> [playwright args...]
211+
run_platform_live() {
212+
local project=$1 name=$2
213+
shift 2
214+
PLATFORM_LIVE_REPORT_PATH="$report_dir/$name/report.json" \
215+
bun run test:platform:live --project="$project" ${PLATFORM_LIVE_GREP:+--grep "$PLATFORM_LIVE_GREP"} "$@"
216+
}
217+
136218
# wait_ready <pid> <health-url> <label>
137219
wait_ready() {
138220
local pid=$1 url=$2 label=$3
@@ -349,6 +431,38 @@ JS
349431
bun run test:landing-seo:e2e
350432
;;
351433

434+
# The zone as deployed: flag on, /platform rewritten to the Platform build. Then the same build
435+
# with the flag off, which must put everyone back on the legacy surface. Both report, whichever
436+
# fails.
437+
platform-live-zone)
438+
platform_live_environment
439+
export SIM_PLATFORM_ORIGIN=http://127.0.0.1:3028
440+
export NEXT_PUBLIC_APP_URL="$E2E_APP_PUBLIC_URL"
441+
start_browser_install platform-live --only-shell chromium
442+
PLATFORM_ORG_UI=true start_app platform-live 3027 'Platform main' record-http-status
443+
start_platform 3028
444+
bun --no-env-file platform/checks/live/support/scripted-agent.ts 3029 \
445+
> "$report_dir/platform-live-agent.log" 2>&1 &
446+
agent_pid=$!
447+
wait_for_browsers
448+
live_status=0
449+
run_platform_live zone platform-live || live_status=$?
450+
stop_app
451+
start_app platform-live-flag-off 3027 'Platform main, flag off' record-http-status
452+
PLATFORM_LIVE_ZONE=1 run_platform_live flag-off platform-live-flag-off || live_status=$?
453+
exit "$live_status"
454+
;;
455+
456+
# No zone at all: built without SIM_PLATFORM_ORIGIN, so even with the flag on nothing may change.
457+
platform-live-flag-off)
458+
platform_live_environment
459+
export PLATFORM_ORG_UI=true
460+
start_browser_install platform-live --only-shell chromium
461+
start_app platform-live-flag-off 3027 'Main, no zone' record-http-status
462+
wait_for_browsers
463+
run_platform_live flag-off platform-live-flag-off
464+
;;
465+
352466
*)
353467
echo "::error::Unknown end-to-end group: $group" >&2
354468
exit 2

‎.github/workflows/platform-design.yml‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,17 @@ on:
1010
- 'scripts/sync-platform-*.ts'
1111
- '.github/workflows/platform-design.yml'
1212
- 'bun.lock'
13+
- '.github/scripts/http-e2e.sh'
1314
workflow_dispatch:
15+
inputs:
16+
live_only:
17+
description: Run only the live jobs, skipping the fixture suites.
18+
type: boolean
19+
default: false
20+
grep:
21+
description: Narrow the live suite to the specs matching this pattern.
22+
type: string
23+
default: ''
1424

1525
permissions:
1626
contents: read
@@ -21,6 +31,7 @@ concurrency:
2131

2232
jobs:
2333
browser:
34+
if: ${{ !inputs.live_only }}
2435
runs-on: ubuntu-24.04
2536
timeout-minutes: 15
2637
steps:
@@ -44,6 +55,7 @@ jobs:
4455
retention-days: 7
4556

4657
desktop:
58+
if: ${{ !inputs.live_only }}
4759
runs-on: macos-26
4860
timeout-minutes: 25
4961
steps:
@@ -69,3 +81,85 @@ jobs:
6981
name: platform-desktop
7082
path: ${{ runner.temp }}/platform-desktop
7183
retention-days: 7
84+
85+
# The Platform against the real stack: Postgres, Redis, migrations, the production main app and
86+
# the production Platform. `zone` is the deployed topology, and then that same build with the flag
87+
# off. `flag-off` builds with no zone at all, so it is the only proof /platform is truly absent.
88+
# Group environment and seed: `.github/scripts/http-e2e.sh`.
89+
live:
90+
name: live (${{ matrix.topology }})
91+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
92+
timeout-minutes: 75
93+
strategy:
94+
fail-fast: false
95+
matrix:
96+
topology: [zone, flag-off]
97+
services:
98+
postgres:
99+
image: pgvector/pgvector:pg17
100+
env:
101+
POSTGRES_USER: postgres
102+
POSTGRES_PASSWORD: postgres
103+
POSTGRES_DB: sim_test
104+
ports:
105+
- 5432:5432
106+
options: >-
107+
--health-cmd "pg_isready -U postgres -d sim_test"
108+
--health-interval 5s
109+
--health-timeout 5s
110+
--health-retries 10
111+
redis:
112+
image: redis:8.2-alpine
113+
ports:
114+
- 6379:6379
115+
options: >-
116+
--health-cmd "redis-cli ping"
117+
--health-interval 5s
118+
--health-timeout 5s
119+
--health-retries 10
120+
env:
121+
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
122+
BETTER_AUTH_SECRET: http-e2e-ci-secret-at-least-32-characters
123+
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
124+
PLATFORM_LIVE_GREP: ${{ inputs.grep }}
125+
steps:
126+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
127+
with:
128+
persist-credentials: false
129+
- uses: ./.github/actions/setup
130+
with:
131+
provider: ${{ vars.CI_PROVIDER }}
132+
turbo-cache-key: turbo-cache-platform-live-${{ matrix.topology }}
133+
- name: Provision the database through migrations
134+
working-directory: packages/db
135+
run: bun run db:migrate
136+
- name: Build the main app
137+
env:
138+
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
139+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000'
140+
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
141+
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
142+
RESEND_API_KEY: 'dummy_key_for_ci_only'
143+
AWS_REGION: 'us-west-2'
144+
TURBO_CACHE_DIR: .turbo
145+
NEXT_BUILD_SKIP_TYPE_CHECK: 'true'
146+
run: |
147+
if [ "${{ matrix.topology }}" = zone ]; then export SIM_PLATFORM_ORIGIN=http://127.0.0.1:3028; fi
148+
bunx turbo run build --filter=@sim/app
149+
- name: Build the Platform under its base path
150+
if: matrix.topology == 'zone'
151+
env:
152+
SIM_PLATFORM_BASE_PATH: /platform
153+
run: bun run --cwd apps/sim build:platform
154+
- name: Map the hosted app to loopback
155+
run: printf '127.0.0.1 platform-live.sim.ai\n' | sudo tee -a /etc/hosts
156+
- name: Run the live suite
157+
working-directory: apps/sim
158+
run: bash ../../.github/scripts/http-e2e.sh platform-live-${{ matrix.topology }}
159+
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
160+
if: always()
161+
with:
162+
name: platform-live-${{ matrix.topology }}
163+
path: ${{ runner.temp }}/e2e/
164+
if-no-files-found: warn
165+
retention-days: 7

‎apps/sim/platform/checks/live/chat.spec.ts‎

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
1-
import { expect, type Page, test } from '@playwright/test'
2-
3-
function requireEnv(name: string): string {
4-
const value = process.env[name]
5-
if (!value) throw new Error(`${name} is required: the live checks use a real seeded member`)
6-
return value
7-
}
8-
9-
async function signIn(page: Page) {
10-
await page.goto('/platform/home')
11-
await page.locator('#email').fill(requireEnv('PLATFORM_LIVE_EMAIL'))
12-
await page.locator('#password').fill(requireEnv('PLATFORM_LIVE_PASSWORD'))
13-
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
14-
await expect(page).toHaveURL(/\/platform\//)
15-
}
1+
import { expect, test } from '@playwright/test'
2+
import { requireEnv, signInAsAdmin } from '@/platform/checks/live/session'
163

174
/** A model turn needs a reachable orchestrator, which not every environment running these has. */
185
const orchestrator = process.env.PLATFORM_LIVE_ORCHESTRATOR === '1'
196

207
test('Home hands a draft to a new chat, which posts it for this organization', async ({ page }) => {
21-
await signIn(page)
8+
await signInAsAdmin(page, '/platform/home')
229
const composer = page.getByRole('textbox', { name: /Ask about your business/ })
2310
await composer.fill('Say hello')
2411
const posted = page.waitForRequest(
@@ -36,22 +23,20 @@ test('Home hands a draft to a new chat, which posts it for this organization', a
3623
test('A chat outside the viewer’s access is not found rather than an empty chat', async ({
3724
page,
3825
}) => {
39-
await signIn(page)
26+
await signInAsAdmin(page, '/platform/home')
4027
const response = await page.goto('/platform/chat/00000000-0000-4000-8000-000000000000')
4128
expect(response?.status()).toBe(404)
4229
})
4330

4431
test('A resource in the URL survives a reload of the chat route', async ({ page }) => {
45-
await signIn(page)
46-
await page.goto('/platform/chat?resource=stale-id')
32+
await signInAsAdmin(page, '/platform/chat?resource=stale-id')
4733
await expect(page.getByRole('textbox')).toBeVisible()
4834
await expect(page).toHaveURL(/resource=stale-id/)
4935
})
5036

5137
test('A running turn streams, stops, and keeps what arrived', async ({ page }) => {
5238
test.skip(!orchestrator, 'Needs PLATFORM_LIVE_ORCHESTRATOR=1')
53-
await signIn(page)
54-
await page.goto('/platform/chat')
39+
await signInAsAdmin(page, '/platform/chat')
5540
await page.getByRole('textbox').fill('Count slowly from 1 to 50, one number per line.')
5641
await page.getByRole('button', { name: 'Send message' }).click()
5742
await expect(page).toHaveURL(/\/platform\/chat\/[^/?]+/, { timeout: 30_000 })
@@ -72,8 +57,7 @@ test('A message sent during a running turn waits in the queue and can be removed
7257
page,
7358
}) => {
7459
test.skip(!orchestrator, 'Needs PLATFORM_LIVE_ORCHESTRATOR=1')
75-
await signIn(page)
76-
await page.goto('/platform/chat')
60+
await signInAsAdmin(page, '/platform/chat')
7761
const field = page.getByRole('textbox')
7862
await field.fill('Count slowly from 1 to 50, one number per line.')
7963
await page.getByRole('button', { name: 'Send message' }).click()
@@ -91,8 +75,7 @@ test('A gated tool holds the turn until the prompt is answered', async ({ page }
9175
!orchestrator || !process.env.PLATFORM_LIVE_APPROVAL_PROMPT,
9276
'Needs an orchestrator and a prompt that triggers a gated tool'
9377
)
94-
await signIn(page)
95-
await page.goto('/platform/chat')
78+
await signInAsAdmin(page, '/platform/chat')
9679
await page.getByRole('textbox').fill(requireEnv('PLATFORM_LIVE_APPROVAL_PROMPT'))
9780
await page.getByRole('button', { name: 'Send message' }).click()
9881
const allow = page.getByRole('button', { name: 'Allow', exact: true })
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, type Page, test } from '@playwright/test'
2+
import { adminEmail, organizationId, signIn } from '@/platform/checks/live/session'
3+
4+
/**
5+
* `PLATFORM_LIVE_ZONE=1` means the app was built with `SIM_PLATFORM_ORIGIN` and is running with the
6+
* flag off; unset, there is no zone at all, even with the flag on.
7+
*/
8+
const zoneBuilt = process.env.PLATFORM_LIVE_ZONE === '1'
9+
10+
const LEGACY_ROUTES = ['home', 'settings/members', 'people'] as const
11+
12+
async function expectLegacySurface(page: Page, route: string) {
13+
const path = `/o/${organizationId()}/${route}`
14+
const response = await page.goto(path)
15+
expect(response?.status()).toBe(200)
16+
expect(response?.request().redirectedFrom()).toBeNull()
17+
await expect(page).toHaveURL(new RegExp(`${path}(?:\\?|$)`))
18+
await expect(page.locator('.workspace-root')).toBeVisible()
19+
await expect(page.locator('[data-platform-ui]')).toHaveCount(0)
20+
}
21+
22+
for (const route of LEGACY_ROUTES) {
23+
test(`/o/[id]/${route} renders the legacy surface with no redirect`, async ({ page }) => {
24+
await signIn(page, adminEmail(), `/o/${organizationId()}/${route}`)
25+
await expectLegacySurface(page, route)
26+
})
27+
}
28+
29+
test('a client navigation stays on the legacy surface too', async ({ page }) => {
30+
await signIn(page, adminEmail(), `/o/${organizationId()}/home`)
31+
await page.getByRole('link', { name: 'People', exact: true }).first().click()
32+
await expect(page).toHaveURL(new RegExp(`/o/${organizationId()}/people`))
33+
await expect(page.locator('[data-platform-ui]')).toHaveCount(0)
34+
})
35+
36+
test('with no zone built, /platform is absent', async ({ page }) => {
37+
test.skip(zoneBuilt, 'This build has a zone; its /platform is exercised by the zone project')
38+
for (const path of ['/platform', '/platform/home', '/platform/settings/members']) {
39+
await signIn(page, adminEmail(), `/o/${organizationId()}/home`)
40+
const response = await page.goto(path)
41+
expect(response?.status(), path).toBe(404)
42+
await expect(page.locator('[data-platform-ui]')).toHaveCount(0)
43+
}
44+
})

0 commit comments

Comments
 (0)