Files
hanzo.ai/e2e/unit/fallback.spec.ts

102 lines
3.7 KiB
TypeScript

import { test, expect } from '@playwright/test'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { deepLinks } from '../../next.config.mjs'
/**
* A DEEP LINK RELOADS. `_fallback.json` declares which path prefixes are an
* app's routes and which page answers them; the site edge (hanzo-inc/cloud
* apps/sites), `scripts/serve.mjs` and the dev rewrites read the same file, so
* /chat/<id> reloaded renders the chat page everywhere instead of a 404.
*
* serve.mjs is driven over a real socket against a throwaway build: a declared
* deep path answers its page 200; a sibling that only shares letters, a path
* with an extension, and every path of a build with no table stay 404; a file
* that exists still wins.
*/
const free = () =>
new Promise<number>((ok) => {
const s = createServer().listen(0, () => {
const { port } = s.address() as { port: number }
s.close(() => ok(port))
})
})
async function serve(files: Record<string, string>) {
const root = mkdtempSync(join(tmpdir(), 'fallback-'))
for (const [name, body] of Object.entries(files)) writeFileSync(join(root, name), body)
const port = await free()
const child: ChildProcess = spawn(process.execPath, ['scripts/serve.mjs', root, String(port)], { stdio: 'pipe' })
await new Promise<void>((ok, fail) => {
child.stdout!.on('data', () => ok())
child.on('exit', (code) => fail(new Error(`serve.mjs exited ${code}`)))
})
const get = async (path: string) => {
const r = await fetch(`http://127.0.0.1:${port}${path}`)
return [r.status, await r.text()] as const
}
return {
get,
stop: () => {
child.kill()
rmSync(root, { recursive: true, force: true })
},
}
}
const build = {
'index.html': 'home',
'chat.html': 'chat page',
'dev.html': 'dev page',
'chat.js': 'chunk',
}
test('a declared deep path answers its page, and nothing else does', async () => {
const s = await serve({ ...build, '_fallback.json': JSON.stringify({ '/chat': 'chat.html', '/dev/': '/dev.html' }) })
try {
expect(await s.get('/chat/abc')).toEqual([200, 'chat page'])
expect(await s.get('/chat/abc/def')).toEqual([200, 'chat page'])
expect(await s.get('/dev/runs/r1')).toEqual([200, 'dev page'])
expect(await s.get('/chat')).toEqual([200, 'chat page'])
expect(await s.get('/chat.js')).toEqual([200, 'chunk'])
expect((await s.get('/chatter'))[0]).toBe(404)
expect((await s.get('/chat/missing.js'))[0]).toBe(404)
expect((await s.get('/elsewhere/x'))[0]).toBe(404)
} finally {
s.stop()
}
})
test('a build without a table, or with one that is not a table, declares nothing', async () => {
for (const table of [undefined, '[]', '{"/chat": "chat.js"}', '{"/chat": 1}', 'not json']) {
const s = await serve(table === undefined ? build : { ...build, '_fallback.json': table })
try {
expect((await s.get('/chat/abc'))[0], String(table)).toBe(404)
} finally {
s.stop()
}
}
})
test('a declared page cannot name a file outside the build', async () => {
const s = await serve({ ...build, '_fallback.json': JSON.stringify({ '/x': '../../../etc/passwd.html' }) })
try {
expect((await s.get('/x/y'))[0]).toBe(404)
} finally {
s.stop()
}
})
test('dev rewrites every declared prefix to its page, after files', () => {
expect(deepLinks({ '/chat': 'chat.html', '/dev/': '/dev.html', '/': 'index.html' })).toEqual([
{ source: '/chat/:rest*', destination: '/chat' },
{ source: '/dev/:rest*', destination: '/dev' },
{ source: '/:rest*', destination: '/index' },
])
expect(deepLinks().map((r) => r.source)).toContain('/chat/:rest*')
})