Skip to content

Commit a0e67ca

Browse files
committed
test(platform): prove a self-hosted deployment boots with /o and no zone
Every live run was hosted Enterprise. The no-zone job now also runs its image as a self-hoster would: not on a Sim hostname, billing off, no rollout flag. The two live jobs cannot read each other's reports, so the parity check takes --job and each job holds its own rows to its own run.
1 parent 14e5535 commit a0e67ca

7 files changed

Lines changed: 109 additions & 8 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,14 +555,25 @@ JS
555555
;;
556556

557557
# No zone at all: built without SIM_PLATFORM_ORIGIN, so even with the flag on nothing may change.
558+
# Then that image as a self-hoster runs it: not on a Sim hostname, billing off, no rollout flag.
559+
# Both report, whichever fails.
558560
platform-live-flag-off)
559561
platform_live_environment
560562
export PLATFORM_ORG_UI=true
563+
export ALLOWED_ORIGINS=http://127.0.0.1:3027
561564
start_browser_install platform-live --only-shell chromium
562565
start_app platform-live-flag-off 3027 'Main, no zone' record-http-status
563566
start_realtime platform-live-flag-off 3030
564567
wait_for_browsers
565-
run_platform_live flag-off platform-live-flag-off
568+
live_status=0
569+
run_platform_live flag-off platform-live-flag-off || live_status=$?
570+
stop_app
571+
unset PLATFORM_ORG_UI BILLING_ENABLED NEXT_PUBLIC_BILLING_ENABLED
572+
unset ENTERPRISE_ENABLED NEXT_PUBLIC_ENTERPRISE_ENABLED
573+
export E2E_APP_PUBLIC_URL=http://127.0.0.1:3027 PLATFORM_LIVE_ORIGIN=http://127.0.0.1:3027
574+
start_app platform-live-self-hosted 3027 'Main, self-hosted' record-http-status
575+
run_platform_live self-hosted platform-live-self-hosted || live_status=$?
576+
exit "$live_status"
566577
;;
567578

568579
*)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ jobs:
112112

113113
# The Platform against the real stack: Postgres, Redis, migrations, the production main app and
114114
# the production Platform. `zone` is the deployed topology, and then that same build with the flag
115-
# off. `flag-off` builds with no zone at all, so it is the only proof /org is truly absent.
115+
# off. `flag-off` builds with no zone at all, so it is the only proof /org is truly absent, and
116+
# then runs that build self-hosted.
116117
# Group environment and seed: `.github/scripts/http-e2e.sh`.
117118
live:
118119
name: live (${{ matrix.topology }})
@@ -189,12 +190,20 @@ jobs:
189190
if: always() && matrix.topology == 'zone'
190191
run: >-
191192
bun run check:platform-parity
193+
--job zone
192194
--report "$RUNNER_TEMP/e2e/platform-live/report.json"
193195
--report "$RUNNER_TEMP/e2e/platform-live-zone-down/report.json"
194196
--report "$RUNNER_TEMP/e2e/platform-live-zone-5xx/report.json"
195197
--report "$RUNNER_TEMP/e2e/platform-live-zone-recovered/report.json"
196198
--report "$RUNNER_TEMP/e2e/platform-live-flag-off/report.json"
197199
--report "$RUNNER_TEMP/e2e/platform-live-rollout/report.json"
200+
- name: Hold the parity ledger's flag-off rows to this run
201+
if: always() && matrix.topology == 'flag-off'
202+
run: >-
203+
bun run check:platform-parity
204+
--job flag-off
205+
--report "$RUNNER_TEMP/e2e/platform-live-flag-off/report.json"
206+
--report "$RUNNER_TEMP/e2e/platform-live-self-hosted/report.json"
198207
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
199208
if: always()
200209
with:

‎apps/sim/platform/checks/live/playwright.config.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { defineConfig } from '@playwright/test'
44
const report = process.env.PLATFORM_LIVE_REPORT_PATH ?? 'test-results/platform-live/report.json'
55

66
/** Each needs its own stack, so each is its own folder and never part of the `zone` run. */
7-
const TOPOLOGIES = ['flag-off', 'zone-down', 'zone-recovered', 'rollout'] as const
7+
const TOPOLOGIES = ['flag-off', 'zone-down', 'zone-recovered', 'rollout', 'self-hosted'] as const
88

99
/**
1010
* Runs against a real main app, Platform zone and database you have already started; it starts no
@@ -16,6 +16,7 @@ const TOPOLOGIES = ['flag-off', 'zone-down', 'zone-recovered', 'rollout'] as con
1616
* - `zone-down`: the flag is on and the zone is gone or answers 5xx to everything.
1717
* - `zone-recovered`: the flag is on and the zone has just come back.
1818
* - `rollout`: flags read from AppConfig (`support/appconfig-service.ts`), targeting one organization.
19+
* - `self-hosted`: no zone, not on a Sim hostname, billing off.
1920
*/
2021
export default defineConfig({
2122
testDir: __dirname,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { expect, test } from '@playwright/test'
2+
import { organizationId, signInAsAdmin } from '@/platform/checks/live/session'
3+
import { expectMainAppSurface, recordZoneRequests } from '@/platform/checks/live/support/surfaces'
4+
5+
/**
6+
* The image a self-hoster runs: no `SIM_PLATFORM_ORIGIN`, so no zone and no rewrite, on a hostname
7+
* that is not Sim's, with billing off and no rollout flag. Every other live run is hosted Enterprise.
8+
*/
9+
test('a self-hosted deployment with no zone boots', async ({ request }) => {
10+
const health = await request.get('/api/health')
11+
expect(health.status()).toBe(200)
12+
})
13+
14+
test('a self-hosted organization is served by the main app', async ({ page }) => {
15+
const home = `/o/${organizationId()}/home`
16+
await signInAsAdmin(page, home)
17+
const surfaceResponse = await page.request.get('/api/organizations/surface')
18+
expect(surfaceResponse.status()).toBe(200)
19+
const surface = (await surfaceResponse.json()) as {
20+
platformOrgUi: boolean
21+
deployment: { hosted: boolean; billingEnabled: boolean }
22+
}
23+
expect(surface.platformOrgUi).toBe(false)
24+
expect(surface.deployment.hosted).toBe(false)
25+
expect(surface.deployment.billingEnabled).toBe(false)
26+
27+
const zoneRequests = recordZoneRequests(page)
28+
for (const route of [home, `/o/${organizationId()}/settings/members`]) {
29+
const response = await page.goto(route)
30+
expect(response?.status(), route).toBe(200)
31+
expect(response?.request().redirectedFrom(), route).toBeNull()
32+
await expect(page).toHaveURL(new RegExp(`${route}(?:\\?|$)`))
33+
await expectMainAppSurface(page)
34+
}
35+
expect(zoneRequests).toEqual([])
36+
})
37+
38+
test('a self-hosted deployment has no /org', async ({ page }) => {
39+
await signInAsAdmin(page, `/o/${organizationId()}/home`)
40+
for (const path of ['/org', '/org/home']) {
41+
const response = await page.goto(path)
42+
expect(response?.status(), path).toBe(404)
43+
}
44+
})

‎apps/sim/platform/docs/org-parity.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,4 @@ The shell lane's rows, one per row of its audit. `shell.spec` is `checks/live/sh
377377
| A zone that recovers takes its organizations back | `lib/platform/zone-availability.ts` | Done | "a zone that comes back takes its organizations back" |
378378
| Per-organization rollout: `platform-org-ui` reaches a targeted organization by id and no other, on the same deployment, through the real AppConfig client against a fixture of its service | `lib/platform/rollout.ts`, `lib/core/config/feature-flags.ts` | Done | "a targeted organization is handed to the Platform", "an organization outside the rollout stays on /o". No real AppConfig deployment has been read |
379379
| A rollback under an open tab: a client navigation keeps working, because the rollout gate lives in a layout that does not re-render, and a reload's 404 offers a way off the zone | `live-shell.tsx`, `lib/data/landing.ts` | Done | "a rollback under an open tab"; `landing.test.ts` for the root's decision. The 404's home button used to loop back to it |
380+
| Self-hosted, no zone, no flag: boots, `/o` is served by the main app, `/org` is absent | `next.config.ts`, `lib/platform/rollout.ts` | Done | "a self-hosted deployment with no zone boots", "a self-hosted organization is served by the main app", "a self-hosted deployment has no /org", in the `live (flag-off)` job |

‎scripts/check-platform-parity.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { auditLedger } from './check-platform-parity'
44
const HEADER =
55
'| Capability | Legacy source | Status | Proof or reason |\n| --- | --- | --- | --- |\n'
66

7-
function audit(rows: string, run?: Map<string, string>) {
7+
function audit(rows: string, run?: Map<string, string>, job?: 'zone' | 'flag-off') {
88
return auditLedger({
99
ledger: `${HEADER}${rows}`,
1010
testFiles: new Set(['home.spec.ts', 'home-model.test.ts']),
1111
liveTitles: ['a role change persists and a failed one is reported'],
1212
run,
13+
job,
1314
})
1415
}
1516

@@ -43,4 +44,24 @@ describe('auditLedger', () => {
4344
expect(audit(row, new Map([[title, 'skipped']]))[0]).toContain('skipped in this run')
4445
expect(audit(row, new Map())[0]).toContain('did not run')
4546
})
47+
48+
it('holds each live job to its own rows, and neither to the other', () => {
49+
const title = 'a role change persists and a failed one is reported'
50+
const zoneRow = '| Role | x | Done | "a role change persists" |\n'
51+
const flagOffRow =
52+
'| Role | x | Done | "a role change persists", in the `live (flag-off)` job |\n'
53+
54+
expect(audit(flagOffRow, new Map(), 'zone')).toEqual([])
55+
expect(audit(zoneRow, new Map(), 'flag-off')).toEqual([])
56+
57+
expect(audit(zoneRow, new Map(), 'zone')[0]).toContain('did not run')
58+
expect(audit(flagOffRow, new Map(), 'flag-off')[0]).toContain('did not run')
59+
expect(audit(flagOffRow, new Map([[title, 'failed']]), 'flag-off')[0]).toContain('failed')
60+
expect(audit(flagOffRow, new Map([[title, 'passed']]), 'flag-off')).toEqual([])
61+
})
62+
63+
it('still refuses a row in another job when its test does not exist', () => {
64+
const row = '| Role | x | Done | "nothing like this", in the `live (flag-off)` job |\n'
65+
expect(audit(row, new Map(), 'zone')[0]).toContain('does not exist')
66+
})
4667
})

‎scripts/check-platform-parity.ts‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
* data without such a check is `Unverified`, and says why.
77
*
88
* With `--report <playwright.json>` (repeatable) it also reads what that run did: a row whose
9-
* proving test failed or was skipped is not Done, whatever the ledger says.
9+
* proving test failed or was skipped is not Done, whatever the ledger says. The two live jobs build
10+
* different apps and cannot read each other's reports, so `--job` holds each to its own rows: a row
11+
* saying it is proved in the `live (flag-off)` job answers to that job's run, and every other row
12+
* answers to `zone`'s.
1013
*/
1114
import { readdirSync, readFileSync } from 'node:fs'
1215
import path from 'node:path'
@@ -19,6 +22,9 @@ const HEADERS = new Set([
1922
])
2023
const FILE_PROOF = /[\w./-]+\.(?:integration\.ts|test\.tsx?|spec\.ts)/g
2124
const TITLE_PROOF = /"([^"]+)"/g
25+
const FLAG_OFF_JOB_PROOF = 'in the `live (flag-off)` job'
26+
const LIVE_JOBS = ['zone', 'flag-off'] as const
27+
type LiveJob = (typeof LIVE_JOBS)[number]
2228

2329
/** Test title to its worst status in a run. */
2430
type RunOutcome = Map<string, string>
@@ -30,6 +36,8 @@ interface LedgerInputs {
3036
/** Titles of every test in the live specs. */
3137
liveTitles: readonly string[]
3238
run?: RunOutcome
39+
/** Which job's run this is. Without it, the run answers for every row. */
40+
job?: LiveJob
3341
}
3442

3543
function cells(line: string): string[] {
@@ -48,7 +56,7 @@ function isLiveSpec(file: string): boolean {
4856
return file.endsWith('.spec.ts') && file.includes('checks/')
4957
}
5058

51-
export function auditLedger({ ledger, testFiles, liveTitles, run }: LedgerInputs): string[] {
59+
export function auditLedger({ ledger, testFiles, liveTitles, run, job }: LedgerInputs): string[] {
5260
const findings: string[] = []
5361
let columns: number | null = null
5462
ledger.split('\n').forEach((line, index) => {
@@ -81,6 +89,7 @@ export function auditLedger({ ledger, testFiles, liveTitles, run }: LedgerInputs
8189
}
8290
if (!status.startsWith('Done')) return
8391

92+
const answersToThisRun = !job || proof.includes(FLAG_OFF_JOB_PROOF) === (job === 'flag-off')
8493
const quoted = [...proof.matchAll(TITLE_PROOF)].map((match) => match[1] as string)
8594
if (quoted.length === 0 && files.every(isLiveSpec)) {
8695
findings.push(
@@ -92,7 +101,7 @@ export function auditLedger({ ledger, testFiles, liveTitles, run }: LedgerInputs
92101
findings.push(`${at}: "${capability}" quotes a test that does not exist: "${title}"`)
93102
continue
94103
}
95-
if (!run) continue
104+
if (!run || !answersToThisRun) continue
96105
const outcomes = matchingTitles(title, run.keys()).map((match) => run.get(match))
97106
if (!outcomes.includes('passed')) {
98107
findings.push(
@@ -145,12 +154,17 @@ function main() {
145154
liveTitles.push(title as string)
146155
}
147156
}
148-
const { values } = parseArgs({ options: { report: { type: 'string', multiple: true } } })
157+
const { values } = parseArgs({
158+
options: { report: { type: 'string', multiple: true }, job: { type: 'string' } },
159+
})
160+
const job = LIVE_JOBS.find((candidate) => candidate === values.job)
161+
if (values.job && !job) throw new Error(`--job must be one of ${LIVE_JOBS.join(', ')}`)
149162
const findings = auditLedger({
150163
ledger: readFileSync(path.join(app, 'platform/docs/org-parity.md'), 'utf8'),
151164
testFiles,
152165
liveTitles,
153166
run: values.report ? readRunOutcome(values.report) : undefined,
167+
job,
154168
})
155169
if (findings.length > 0) {
156170
console.error(`check:platform-parity: ${findings.length} finding(s)\n`)

0 commit comments

Comments
 (0)