import assert from 'node:assert/strict'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { closeSync, openSync, readFileSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer } from 'node:net'
import { dirname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, webkit } from 'playwright'
declare global {
var __frontendDocument: number | undefined
}
// This smoke runs the ordinary CLI with isolated project state and source files.
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
await mkdir(join(root, '.tmp'), { recursive: true })
const directory = await mkdtemp(join(root, '.tmp/frontend-hmr-'))
const source = relative(root, directory).replaceAll('\\', '/')
const appPath = join(directory, 'App.tsx')
const cssPath = join(directory, 'app.css')
const sharedPath = join(directory, 'Shared.tsx')
const configPath = join(directory, 'vite.config.ts')
const logPath = join(directory, 'native.log')
const binary = process.env.BLDR_FRONTEND_TEST_BINARY ?? join(directory, 'bldr')
const appSource = `import React, { useState } from 'react'
import { useBldrContext } from '@aptre/bldr-react'
import Shared from '@s4wave/web/hmr-fixture'
import './app.css'
export default function App() {
const [count, setCount] = useState(0)
const context = useBldrContext()
return HMR before
{context?.webView ? 'context ready' : 'context missing'}
}
`
const cssSource = '#hmr-count { color: rgb(255, 0, 0); }\n'
const sharedSource =
'export default function Shared() { return
Shared before
}\n'
const configSource = `import config from '../../vite.config.ts'
import { resolve } from 'node:path'
export default {
...config,
resolve: { ...config.resolve, alias: [
{ find: '@s4wave/web/hmr-fixture', replacement: resolve(${JSON.stringify(directory)}, 'Shared.tsx') },
...config.resolve.alias,
] },
server: { watch: { ignored: ['**/state/**', '**/.bldr*/**', '**/vendor/**', '**/*.log'] } },
}
`
await writeFile(configPath, configSource)
await writeFile(
join(directory, 'bldr.star'),
`
project(id="frontend-hmr-check", start=start_config(plugins=["web", "hmr-app"], loadWebStartup="bldr/e2e/downstreamapp/testdata/app/web/startup.tsx"))
manifest("web", builder="bldr/web/plugin/compiler", config=web_plugin_compiler_config())
manifest("hmr-app", builder="bldr/plugin/compiler/js", config=js_plugin_config(webPluginId="web", viteConfigPaths=[${JSON.stringify(source + '/vite.config.ts')}], viteDisableProjectConfig=True, modules=[js_module("JS_MODULE_KIND_FRONTEND", ${JSON.stringify(source + '/App.tsx')}, entrypoint=True, webViewParentId={"empty": True})]))
build("web", manifests=["web", "hmr-app"], targets=["browser"])
`,
)
// Reserve a local address before starting the CLI's listener.
const reservation = createServer()
await new Promise((ready) => reservation.listen(0, '127.0.0.1', ready))
const address = reservation.address()
assert(address && typeof address !== 'string')
const origin = `http://127.0.0.1:${address.port}`
await new Promise((closed) => reservation.close(() => closed()))
let native: ChildProcess | undefined
let exited: Promise | undefined
async function startNative(): Promise {
const output = openSync(logPath, 'a')
native = spawn(
binary,
[
`--bldr-src-path=${root}`,
`--state-path=${join(directory, 'state')}`,
`--config=${join(directory, 'bldr.yaml')}`,
'--log-level=debug',
'--no-tui',
'--minify-entrypoint=false',
'start',
'web',
`--listen=127.0.0.1:${address.port}`,
],
{ cwd: root, stdio: ['ignore', output, output] },
)
closeSync(output)
exited = new Promise((done, reject) => {
native!.once('error', reject)
native!.once('exit', () => done())
})
const deadline = Date.now() + 90000
while (Date.now() < deadline) {
if (native.exitCode !== null || native.signalCode !== null) {
throw new Error('Bldr exited during startup; see ' + logPath)
}
try {
if ((await fetch(origin)).ok) return
} catch {
// The CLI has not opened its listener yet.
}
await new Promise((done) => setTimeout(done, 100))
}
throw new Error('Bldr startup did not become ready; see ' + logPath)
}
async function stopNative(): Promise {
if (native && native.exitCode === null && native.signalCode === null)
native.kill('SIGINT')
await exited
native = undefined
}
function publications(): number {
return (
readFileSync(logPath, 'utf8').split('committing manifest to world').length -
1
)
}
try {
if (!process.env.BLDR_FRONTEND_TEST_BINARY) {
const built = spawnSync(
'go',
['build', '-mod=readonly', '-o', binary, './bldr/cmd/bldr'],
{ cwd: root, stdio: 'inherit' },
)
assert.equal(built.status, 0, 'build the Bldr CLI')
}
await writeFile(appPath, appSource)
await writeFile(cssPath, cssSource)
await writeFile(sharedPath, sharedSource)
await startNative()
for (const [name, engine] of Object.entries({ chromium, webkit })) {
if (
process.env.BLDR_FRONTEND_TEST_BROWSER &&
process.env.BLDR_FRONTEND_TEST_BROWSER !== name
)
continue
const browser = await engine.launch({ headless: true })
const deadline = setTimeout(() => void browser.close(), 110000)
const page = await browser.newPage()
page.setDefaultTimeout(15000)
let workers = 0
const pageErrors: string[] = []
const diagnostics: string[] = []
page.on('console', (message) => {
if (/frontend|devtool|dial|WebSocket/i.test(message.text())) {
diagnostics.push(message.text())
if (diagnostics.length > 30) diagnostics.shift()
}
})
page.on('worker', () => workers++)
page.on('pageerror', (error) => pageErrors.push(error.message))
await page.addInitScript(() => {
globalThis.__frontendDocument = Math.random()
})
try {
await page.goto(origin, { waitUntil: 'domcontentloaded' })
await page.locator('#hmr-count').waitFor({ timeout: 60000 })
await page.locator('#hmr-count').click({ clickCount: 2 })
const documentID = await page.evaluate(
() => globalThis.__frontendDocument,
)
const initialWorkers = workers
const initialPublications = publications()
const changed = Date.now()
await writeFile(cssPath, '#hmr-count { color: rgb(0, 0, 255); }\n')
await page.waitForFunction(
() =>
getComputedStyle(document.querySelector('#hmr-count')!).color ===
'rgb(0, 0, 255)',
)
const cssMs = Date.now() - changed
const reactChanged = Date.now()
await writeFile(appPath, appSource.replace('HMR before', 'HMR after'))
await page
.getByRole('heading', { name: 'HMR after', exact: true })
.waitFor()
const reactMs = Date.now() - reactChanged
await writeFile(
sharedPath,
sharedSource.replace('Shared before', 'Shared after'),
)
await page.getByText('Shared after', { exact: true }).waitFor()
assert.equal(
await page.locator('#hmr-context').innerText(),
'context ready',
)
assert.equal(await page.locator('#hmr-count').innerText(), 'Count 2')
assert.equal(
await page.evaluate(() => globalThis.__frontendDocument),
documentID,
)
assert.equal(workers, initialWorkers)
assert.equal(publications(), initialPublications)
console.log(name, 'warm updates', {
cssMs,
reactMs,
frontendPublications: 0,
backendReplacements: 0,
})
await writeFile(appPath, 'export default function broken( {')
await page.locator('vite-error-overlay').waitFor()
await writeFile(appPath, appSource.replace('HMR before', 'HMR recovered'))
await page
.getByRole('heading', { name: 'HMR recovered', exact: true })
.waitFor()
await page.locator('vite-error-overlay').waitFor({ state: 'detached' })
for (let i = 1; i <= 3; i++)
await writeFile(
appPath,
appSource.replace('HMR before', `HMR rapid ${i}`),
)
await page
.getByRole('heading', { name: 'HMR rapid 3', exact: true })
.waitFor()
assert.equal(await page.locator('#hmr-count').innerText(), 'Count 2')
// Exceed the snapshot compiler's former 30-second idle release delay.
await new Promise((done) => setTimeout(done, 31000))
await writeFile(
appPath,
appSource.replace('HMR before', 'HMR after idle'),
)
await page
.getByRole('heading', { name: 'HMR after idle', exact: true })
.waitFor()
assert.equal(await page.locator('#hmr-count').innerText(), 'Count 2')
assert.equal(publications(), initialPublications)
const routes = await page.evaluate(async (entrypoint) => {
const current = await fetch(
'/b/fe/entrypoint/' + entrypoint + '?t=4242',
)
const expired = await fetch('/b/fe/expired/' + entrypoint)
return {
current: current.status,
target: current.url,
expired: expired.status,
}
}, source + '/App.tsx')
assert.equal(routes.current, 200)
assert.equal(routes.expired, 410)
assert(routes.target.endsWith('?t=4242'))
const configNavigation = page.waitForEvent('domcontentloaded')
await writeFile(configPath, configSource + `\n// ${name} replacement\n`)
await configNavigation
await page.locator('#hmr-count').waitFor({ timeout: 60000 })
assert.notEqual(
await page.evaluate(() => globalThis.__frontendDocument),
documentID,
)
assert.equal(await page.locator('#hmr-count').innerText(), 'Count 0')
console.log(name, 'configuration replacement passed')
// Interrupt the real devtool transport and edit while its owner is absent.
await stopNative()
await writeFile(
appPath,
appSource.replace('HMR before', 'HMR reconnected'),
)
const reconnectNavigation = page.waitForEvent('domcontentloaded', {
timeout: 30000,
})
await startNative()
await reconnectNavigation
await page
.getByRole('heading', { name: 'HMR reconnected', exact: true })
.waitFor({ timeout: 60000 })
assert.equal(pageErrors.length, 0, pageErrors.join('\n'))
console.log(
name,
'syntax, rapid saves, idle, config replacement, and devtool restart passed',
)
} catch (error) {
console.error(name, 'browser transport diagnostics', diagnostics)
throw error
} finally {
clearTimeout(deadline)
await browser.close()
await writeFile(appPath, appSource)
await writeFile(cssPath, cssSource)
await writeFile(sharedPath, sharedSource)
}
}
} catch (error) {
console.error('Frontend smoke failed; native log:', logPath)
console.error(
(await readFile(logPath, 'utf8')).split('\n').slice(-30).join('\n'),
)
throw error
} finally {
await stopNative()
const processes = spawnSync('ps', ['-Ao', 'command'], {
encoding: 'utf8',
}).stdout
assert(
!processes.split('\n').some((line) => line.includes(directory)),
'Bldr left an owned compiler process running',
)
await rm(directory, { recursive: true, force: true })
}