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
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,9 @@ jobs:
# package `@meith/web`'s own dependency closure newly requires (none of
# them are on the real npm registry yet), scaffolds a board exactly as a
# user would, installs it from the packed tarballs, and builds and boots
# the result. See docs/development.md, "Consuming the board from a
# the result — twice, once at each materialization depth, because Vercel
# deploys through `--at-root` and every board bug found so far shipped
# through that one. See docs/development.md, "Consuming the board from a
# workspace", and scripts/board-workspace-smoke.mts for the mechanism and
# why the boot check runs against Postgres rather than fixture mode.
board-workspace:
Expand Down Expand Up @@ -424,7 +426,7 @@ jobs:

- run: pnpm install --frozen-lockfile

- name: Pack, scaffold, install, build and boot
- name: Pack, scaffold, install, build and boot at both depths
run: pnpm board:workspace:smoke
env:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/board_workspace_smoke
Expand Down
18 changes: 18 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,24 @@ colours emit `gname-<id>` styled from an inline `<style>`, none of which are
Tailwind's to generate — a gate that failed on those would be failing correct
boards.

**All of it runs twice, once at each materialization depth.** The first board
is built and booted the way a self-hoster gets it, at `.meith/app`; a second
board is scaffolded, installed from the same packed tarballs and built with
`--at-root`, the way Vercel deploys it. That second board exists because every
board bug found so far — the unregistered theme catalog, the Tailwind scan
roots resolving to nothing, the installer that could not finish — shipped
through the depth-zero path while the depth-two smoke stayed green, and
`rebaseGlobalsCssSources` genuinely computes a different path at each depth
rather than the same one twice.

Two things it does not prove, worth saying so nobody reads more into a green
run than is there. The second board boots through the standalone server,
because `output: 'standalone'` is skipped only when `VERCEL` is set and CI is
not Vercel; Vercel packages its own functions instead. And it is a fresh
board rather than the first one rebuilt, so nothing here says the two depths
can coexist in one workspace — no board does that, and a smoke that failed on
it would be failing something no user can reach.

### Building where Vercel looks

`forum-web build --at-root` materializes into the workspace root itself
Expand Down
143 changes: 75 additions & 68 deletions scripts/board-workspace-smoke.mts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ if (!DATABASE_URL) {
}

const PORT = process.env.SMOKE_PORT ?? '3999'
const AT_ROOT_PORT = process.env.SMOKE_AT_ROOT_PORT ?? String(Number(PORT) + 1)
const AT_ROOT_FLAG = '--at-root'
const AUTH_SECRET = 'smoke-test-auth-secret-32-bytes-min'
const TICK_SECRET = 'smoke-test-tick-secret-32-bytes-min'

Expand Down Expand Up @@ -98,19 +100,19 @@ function runCapturingStdout(

const CLOSURE_ROOTS = ['@meith/web', '@meith/cli', '@meith/theme-default']

async function scaffoldBoard(parentDir: string): Promise<string> {
async function scaffoldBoard(parentDir: string, name = 'smoke-board'): Promise<string> {
const { run: runCreateMeith } = await import(join(ROOT, 'packages/create-meith/src/cli.ts'))
const previousCwd = process.cwd()
process.chdir(parentDir)
try {
const result = await runCreateMeith(['smoke-board'], '0.0.0-smoke')
const result = await runCreateMeith([name], '0.0.0-smoke')
if (result.code !== 0) {
throw new Error(`create-meith failed:\n${result.lines.join('\n')}`)
}
} finally {
process.chdir(previousCwd)
}
return join(parentDir, 'smoke-board')
return join(parentDir, name)
}

async function pointAtTarballs(boardDir: string, tarballs: ReadonlyMap<string, string>) {
Expand Down Expand Up @@ -144,6 +146,66 @@ async function waitForResponse(url: string, attempts: number): Promise<Response>
throw new Error(`board-workspace-smoke: ${url} never answered: ${String(lastError)}`)
}

async function bootAndCheck(boardDir: string, port: string, atRoot: boolean) {
const label = atRoot ? 'at the project root' : 'at .meith/app'
const flag = atRoot ? [AT_ROOT_FLAG] : []

console.log(`== forum-web start ${label} ==`)
const server = spawn(join(boardDir, 'node_modules/.bin/forum-web'), ['start', ...flag], {
cwd: boardDir,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PORT: port,
DATABASE_URL,
DATA_SOURCE: 'postgres',
AUTH_SECRET,
TICK_SECRET,
APP_URL: `http://127.0.0.1:${port}`,
},
})
server.stdout?.on('data', (chunk) => process.stdout.write(chunk))
server.stderr?.on('data', (chunk) => process.stderr.write(chunk))

function stopServer() {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGTERM')
} catch {}
setTimeout(() => {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGKILL')
} catch {}
}, 5000).unref()
}

try {
console.log('== waiting for it to answer / ==')
const response = await waitForResponse(`http://127.0.0.1:${port}/`, 40)
if (!response.ok) {
throw new Error(`board-workspace-smoke: / answered ${response.status} (${label})`)
}
const body = await response.text()
if (!body.includes('<main')) {
throw new Error(`board-workspace-smoke: / answered but did not render <main> (${label})`)
}
assertMessagesResolve(body, Object.keys(defaultEnMessages))
console.log(`== the board materialized ${label} rendered / ==`)

console.log('== confirming static assets and /sw.js actually serve ==')
await assertBoardAssetsServe(`http://127.0.0.1:${port}`, body)
console.log('== static assets and /sw.js served correctly ==')

console.log('== confirming the stylesheet actually styles what rendered ==')
await assertStylesResolve(`http://127.0.0.1:${port}`, body)
console.log('== every class the board rendered has a rule ==')
} finally {
stopServer()
}
}

async function main() {
const tarballDir = await mkdtemp(join(tmpdir(), 'board-workspace-smoke-tarballs-'))
const scaffoldParent = await mkdtemp(join(tmpdir(), 'board-workspace-smoke-board-'))
Expand Down Expand Up @@ -202,72 +264,17 @@ async function main() {
)
}

console.log('== forum-web start ==')
const server = spawn(join(boardDir, 'node_modules/.bin/forum-web'), ['start'], {
cwd: boardDir,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PORT,
DATABASE_URL,
DATA_SOURCE: 'postgres',
AUTH_SECRET,
TICK_SECRET,
APP_URL: `http://127.0.0.1:${PORT}`,
},
})
server.stdout?.on('data', (chunk) => process.stdout.write(chunk))
server.stderr?.on('data', (chunk) => process.stderr.write(chunk))

/**
* `forum-web start` is itself a wrapper (apps/community/bin/forum-web.mjs)
* that spawns the real `node server.js` as *its own* child with
* `stdio: 'inherit'`. Killing the wrapper does not kill that
* grandchild — Linux does not cascade a signal to orphaned children —
* so `detached: true` on the spawn above plus signalling the whole
* process group (`-server.pid`) here is what actually reaches the
* standalone server, not just its wrapper. Confirmed against a real CI
* run: the smoke test itself passed in under two minutes, but the
* process then sat for over an hour until the run was cancelled — see
* the PR for the log.
*/
function stopServer() {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGTERM')
} catch {}
setTimeout(() => {
if (server.pid === undefined) return
try {
process.kill(-server.pid, 'SIGKILL')
} catch {}
}, 5000).unref()
}

try {
console.log('== waiting for it to answer / ==')
const response = await waitForResponse(`http://127.0.0.1:${PORT}/`, 40)
if (!response.ok) {
throw new Error(`board-workspace-smoke: / answered ${response.status}`)
}
const body = await response.text()
if (!body.includes('<main')) {
throw new Error('board-workspace-smoke: / answered but did not render <main>')
}
assertMessagesResolve(body, Object.keys(defaultEnMessages))
console.log('== the materialized, standalone board rendered / ==')
await bootAndCheck(boardDir, PORT, false)

console.log('== confirming static assets and /sw.js actually serve ==')
await assertBoardAssetsServe(`http://127.0.0.1:${PORT}`, body)
console.log('== static assets and /sw.js served correctly ==')

console.log('== confirming the stylesheet actually styles what rendered ==')
await assertStylesResolve(`http://127.0.0.1:${PORT}`, body)
console.log('== every class the board rendered has a rule ==')
} finally {
stopServer()
}
console.log('== a second board, materialized the way Vercel deploys it ==')
const atRootDir = await scaffoldBoard(scaffoldParent, 'smoke-board-at-root')
await pointAtTarballs(atRootDir, tarballs)
run('npm', ['install'], { cwd: atRootDir })
run(join(atRootDir, 'node_modules/.bin/forum-web'), ['build', AT_ROOT_FLAG], {
cwd: atRootDir,
env: { ...process.env, DATABASE_URL: '', DATA_SOURCE: '' },
})
await bootAndCheck(atRootDir, AT_ROOT_PORT, true)
} finally {
await rm(tarballDir, { recursive: true, force: true })
await rm(scaffoldParent, { recursive: true, force: true })
Expand Down