|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * What a server with no built Console answers at `/_console/` and at `/`. |
| 5 | + * |
| 6 | + * Before the not-built answer existed, `os serve` mounted nothing when the |
| 7 | + * console package resolved without a `dist/`: `GET /_console/` got the |
| 8 | + * router's bare not-found and `GET /` an empty 404, the same answers a wrong |
| 9 | + * URL gets, and only the boot warning said why. `os serve` now mounts |
| 10 | + * `createConsoleNotBuiltPlugin` on that boot, and these tests pin what it |
| 11 | + * answers: |
| 12 | + * |
| 13 | + * - `GET /_console/` (and every path under it) answers `503` with the remedy |
| 14 | + * the boot warning names — the same sentence from the same picker, with |
| 15 | + * the host's absolute paths left out; |
| 16 | + * - `GET /` and `GET /_console` redirect to `/_console/`, exactly as they do |
| 17 | + * once the Console is built (the control below), so neither route changes |
| 18 | + * owner with the build. |
| 19 | + * |
| 20 | + * These tests run the REAL plugins on a REAL Hono app (the `HonoHttpServer` |
| 21 | + * that production resolves as `http.server`). |
| 22 | + */ |
| 23 | + |
| 24 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
| 25 | +import fs from 'node:fs'; |
| 26 | +import os from 'node:os'; |
| 27 | +import path from 'node:path'; |
| 28 | +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; |
| 29 | + |
| 30 | +import { |
| 31 | + createConsoleNotBuiltPlugin, |
| 32 | + createConsoleStaticPlugin, |
| 33 | + formatConsoleDistMissingWarning, |
| 34 | +} from './console.js'; |
| 35 | + |
| 36 | +const ORIGIN = 'http://console.example.test'; |
| 37 | + |
| 38 | +/** The first line of the not-built answer, which says what is wrong before saying how to fix it. */ |
| 39 | +const NOT_BUILT_HEADLINE = 'The ObjectStack Console is not built, so this server has no Console to serve at /_console/.'; |
| 40 | + |
| 41 | +/** The framework repo's remedy. */ |
| 42 | +const BUILD_REMEDY = 'pnpm objectui:build'; |
| 43 | + |
| 44 | +/** The prefix the terminal warning carries and the HTTP answer does not. */ |
| 45 | +const WARNING_PREFIX = ' ⚠ '; |
| 46 | + |
| 47 | +/** Requests the not-built answer must cover: the shell, a client-side route, and a hashed asset. */ |
| 48 | +const CONSOLE_REQUESTS = ['/_console/', '/_console/apps/crm/records/42', '/_console/assets/index-ocmkyCt6.js']; |
| 49 | + |
| 50 | +type Request = (p: string, init?: RequestInit) => Promise<Response>; |
| 51 | + |
| 52 | +let scratch: string; |
| 53 | + |
| 54 | +beforeAll(() => { |
| 55 | + scratch = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'os-test-console-not-built-'))); |
| 56 | +}); |
| 57 | + |
| 58 | +afterAll(() => { |
| 59 | + fs.rmSync(scratch, { recursive: true, force: true }); |
| 60 | +}); |
| 61 | + |
| 62 | +function writeJson(file: string, value: unknown): void { |
| 63 | + fs.mkdirSync(path.dirname(file), { recursive: true }); |
| 64 | + fs.writeFileSync(file, JSON.stringify(value)); |
| 65 | +} |
| 66 | + |
| 67 | +/** A console package with a manifest and no `dist/` — what the boot warning is printed for. */ |
| 68 | +function writeUnbuiltConsole(dir: string): string { |
| 69 | + writeJson(path.join(dir, 'package.json'), { name: '@objectstack/console', version: '1.0.0' }); |
| 70 | + return dir; |
| 71 | +} |
| 72 | + |
| 73 | +/** The framework repo's shape: the root manifest declares the console build script. */ |
| 74 | +function frameworkTree(): { root: string; consoleDir: string } { |
| 75 | + const root = path.join(scratch, 'framework'); |
| 76 | + writeJson(path.join(root, 'package.json'), { |
| 77 | + name: 'framework-root', |
| 78 | + private: true, |
| 79 | + scripts: { build: 'turbo run build', 'objectui:build': 'bash scripts/build-console.sh' }, |
| 80 | + }); |
| 81 | + return { root, consoleDir: writeUnbuiltConsole(path.join(root, 'packages', 'console')) }; |
| 82 | +} |
| 83 | + |
| 84 | +/** A project that installed the CLI, whose `@objectstack/console` arrived without its `dist/`. */ |
| 85 | +function installTree(): { project: string; consoleDir: string } { |
| 86 | + const project = path.join(scratch, 'app'); |
| 87 | + writeJson(path.join(project, 'package.json'), { name: 'consumer-app', scripts: { dev: 'objectstack dev' } }); |
| 88 | + return { |
| 89 | + project, |
| 90 | + consoleDir: writeUnbuiltConsole(path.join(project, 'node_modules', '@objectstack', 'console')), |
| 91 | + }; |
| 92 | +} |
| 93 | + |
| 94 | +async function serve(plugin: { start: (ctx: any) => Promise<void> }): Promise<Request> { |
| 95 | + const server = new HonoHttpServer(0); |
| 96 | + await plugin.start({ getServiceAsync: async () => server, logger: { warn: () => {} } }); |
| 97 | + const app = server.getRawApp(); |
| 98 | + return async (p, init) => app.request(`${ORIGIN}${p}`, init); |
| 99 | +} |
| 100 | + |
| 101 | +/** `/` and `/_console` redirect to the Console's shell, built or not. */ |
| 102 | +async function expectRedirectsToConsole(request: Request): Promise<void> { |
| 103 | + for (const p of ['/', '/_console']) { |
| 104 | + const res = await request(p); |
| 105 | + expect(res.status, `GET ${p}`).toBe(302); |
| 106 | + expect(res.headers.get('location'), `GET ${p}`).toBe('/_console/'); |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +describe('a framework checkout with no built Console', () => { |
| 111 | + let request: Request; |
| 112 | + let root: string; |
| 113 | + let consoleDir: string; |
| 114 | + beforeAll(async () => { |
| 115 | + ({ root, consoleDir } = frameworkTree()); |
| 116 | + request = await serve(createConsoleNotBuiltPlugin(consoleDir)); |
| 117 | + }); |
| 118 | + |
| 119 | + it.each(CONSOLE_REQUESTS)('GET %s answers 503 naming `pnpm objectui:build`', async (p) => { |
| 120 | + const res = await request(p); |
| 121 | + expect(res.status).toBe(503); |
| 122 | + expect(res.headers.get('content-type')).toMatch(/^text\/plain/); |
| 123 | + expect(res.headers.get('cache-control')).toBe('no-store'); |
| 124 | + const body = await res.text(); |
| 125 | + expect(body.split('\n')[0]).toBe(NOT_BUILT_HEADLINE); |
| 126 | + expect(body).toContain(BUILD_REMEDY); |
| 127 | + }); |
| 128 | + |
| 129 | + it('is the boot warning\'s sentence, with only the absolute build root left out', async () => { |
| 130 | + const warning = formatConsoleDistMissingWarning(consoleDir); |
| 131 | + expect(warning.startsWith(WARNING_PREFIX)).toBe(true); |
| 132 | + // The warning names the root to run the script in; the HTTP answer says |
| 133 | + // where in words. Everything either side of that path is the same text. |
| 134 | + const [before, after] = warning.slice(WARNING_PREFIX.length).split(` in ${root} `); |
| 135 | + expect(after, 'the warning no longer names the build root this test splits on').toBeDefined(); |
| 136 | + |
| 137 | + const body = await (await request('/_console/')).text(); |
| 138 | + expect(body).toContain(before); |
| 139 | + expect(body).toContain(after); |
| 140 | + expect(body).toContain(path.join('packages', 'console', 'dist')); |
| 141 | + expect(body).not.toContain(root); |
| 142 | + }); |
| 143 | + |
| 144 | + it('redirects `/` and `/_console` to `/_console/`, as a built Console does', async () => { |
| 145 | + await expectRedirectsToConsole(request); |
| 146 | + }); |
| 147 | +}); |
| 148 | + |
| 149 | +describe('an installed CLI whose `@objectstack/console` has no `dist/`', () => { |
| 150 | + let request: Request; |
| 151 | + let project: string; |
| 152 | + let consoleDir: string; |
| 153 | + beforeAll(async () => { |
| 154 | + ({ project, consoleDir } = installTree()); |
| 155 | + request = await serve(createConsoleNotBuiltPlugin(consoleDir)); |
| 156 | + }); |
| 157 | + |
| 158 | + it('answers 503 with the reinstall remedy, the boot warning\'s sentence without the dist path', async () => { |
| 159 | + const warning = formatConsoleDistMissingWarning(consoleDir); |
| 160 | + const dist = path.join(consoleDir, 'dist'); |
| 161 | + const [before, after] = warning.slice(WARNING_PREFIX.length).split(` at ${dist}`); |
| 162 | + expect(after, 'the warning no longer names the dist path this test splits on').toBeDefined(); |
| 163 | + |
| 164 | + const res = await request('/_console/'); |
| 165 | + expect(res.status).toBe(503); |
| 166 | + const body = await res.text(); |
| 167 | + expect(body.split('\n')[0]).toBe(NOT_BUILT_HEADLINE); |
| 168 | + expect(body).toContain(`${before}${after}`); |
| 169 | + expect(body).toContain('@objectstack/console'); |
| 170 | + expect(body).not.toContain(BUILD_REMEDY); |
| 171 | + expect(body).not.toContain(project); |
| 172 | + }); |
| 173 | + |
| 174 | + it('redirects `/` and `/_console` to `/_console/`', async () => { |
| 175 | + await expectRedirectsToConsole(request); |
| 176 | + }); |
| 177 | +}); |
| 178 | + |
| 179 | +describe('controls', () => { |
| 180 | + it('a BUILT Console serves its SPA shell at `/_console/` and the same redirects', async () => { |
| 181 | + const dist = path.join(scratch, 'built', 'dist'); |
| 182 | + fs.mkdirSync(dist, { recursive: true }); |
| 183 | + fs.writeFileSync(path.join(dist, 'index.html'), '<!doctype html><html><head></head><body>console shell</body></html>'); |
| 184 | + const request = await serve(createConsoleStaticPlugin(dist)); |
| 185 | + |
| 186 | + const res = await request('/_console/'); |
| 187 | + expect(res.status).toBe(200); |
| 188 | + expect(res.headers.get('content-type')).toMatch(/^text\/html/); |
| 189 | + expect(await res.text()).toContain('console shell'); |
| 190 | + await expectRedirectsToConsole(request); |
| 191 | + }); |
| 192 | + |
| 193 | + it('with no Console plugin mounted, both paths are the bare 404 the not-built answer replaces', async () => { |
| 194 | + // What `--no-ui` composes, and what a not-built boot composed before: the |
| 195 | + // answers above come from the plugin, not from the server. |
| 196 | + const request = await serve({ start: async () => {} }); |
| 197 | + for (const p of ['/', '/_console/']) { |
| 198 | + const res = await request(p); |
| 199 | + expect(res.status, `GET ${p}`).toBe(404); |
| 200 | + expect(await res.text(), `GET ${p}`).not.toContain(NOT_BUILT_HEADLINE); |
| 201 | + } |
| 202 | + }); |
| 203 | +}); |
0 commit comments