|
| 1 | +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. |
| 2 | +// |
| 3 | +// Pins #10325: the startup banner (`◆ Create ObjectStack …`) names the |
| 4 | +// version `create-objectstack`'s own package.json actually declares, not a |
| 5 | +// hardcoded literal — the banner had said `v6.x` for eleven majors, the |
| 6 | +// first line of output a newcomer ever sees. |
| 7 | +// |
| 8 | +// Two distinct properties, pinned separately so neither can go vacuous: |
| 9 | +// |
| 10 | +// 1. The banner's version text matches package.json's real `version` |
| 11 | +// field, read at test time (never a copy-pasted literal here — a test |
| 12 | +// that hardcoded "17.1.0" would itself go stale the next time this |
| 13 | +// package bumps, the same failure mode the card exists to close). |
| 14 | +// 2. The three box lines still render to EQUAL display width with the |
| 15 | +// borders aligned, computed from PLAIN, ANSI-stripped text — a test |
| 16 | +// that only greps for the version string would still pass with the |
| 17 | +// right border pushed out of alignment (the #10322 defect class, one |
| 18 | +// function away in the same file: a box hand-kerned for one string |
| 19 | +// length, broken by a longer one). |
| 20 | +// |
| 21 | +// `renderVersionBanner` lives in banner.ts specifically so it can be unit |
| 22 | +// tested directly with synthetic version strings (including a long |
| 23 | +// prerelease, to exercise the box-widening path) without spawning a |
| 24 | +// subprocess. `index.ts` itself calls `program.parse()` at module scope (see |
| 25 | +// the comment above `rewriteProjectIdentity`), so the *wiring* — that the |
| 26 | +// real CLI actually calls this function with the real declared version — is |
| 27 | +// covered separately below via `tsx`, the same no-build subprocess pattern |
| 28 | +// `scaffold-description.test.ts` and `scaffold-next-steps-pm.test.ts` use. |
| 29 | + |
| 30 | +import { describe, it, expect } from 'vitest'; |
| 31 | +import { execFileSync } from 'node:child_process'; |
| 32 | +import fs from 'node:fs'; |
| 33 | +import os from 'node:os'; |
| 34 | +import path from 'node:path'; |
| 35 | +import { fileURLToPath } from 'node:url'; |
| 36 | + |
| 37 | +import { renderVersionBanner } from './banner.js'; |
| 38 | + |
| 39 | +const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 40 | +const PKG_ROOT = path.resolve(HERE, '..'); |
| 41 | +const REPO_ROOT = path.resolve(PKG_ROOT, '..', '..'); |
| 42 | +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); |
| 43 | +const INDEX_TS = path.join(PKG_ROOT, 'src', 'index.ts'); |
| 44 | + |
| 45 | +// Built via fromCharCode rather than a literal escape in source, so nothing |
| 46 | +// here can be silently re-materialized into a raw control byte on disk. |
| 47 | +const ESC = String.fromCharCode(27); |
| 48 | +/** Strip SGR color codes so a chalk-styled line measures the same as plain text. */ |
| 49 | +const stripAnsi = (s: string): string => s.replace(new RegExp(ESC + '\\[[0-9;]*m', 'g'), ''); |
| 50 | + |
| 51 | +/** Assert the three banner lines render to equal PLAIN width with aligned borders. */ |
| 52 | +function expectAlignedBox(lines: string[]): void { |
| 53 | + expect(lines).toHaveLength(3); |
| 54 | + const plain = lines.map(stripAnsi); |
| 55 | + const widths = plain.map((l) => [...l].length); |
| 56 | + expect(widths[1]).toBe(widths[0]); |
| 57 | + expect(widths[2]).toBe(widths[0]); |
| 58 | + // Borders: '╔'/'║'/'╚' open the line, '╗'/'║'/'╝' close it — verifying |
| 59 | + // this (rather than just equal length) catches a padding bug that drops |
| 60 | + // characters from the middle while coincidentally preserving total width. |
| 61 | + expect(plain[0].endsWith('╗')).toBe(true); |
| 62 | + expect(plain[1].endsWith('║')).toBe(true); |
| 63 | + expect(plain[2].endsWith('╝')).toBe(true); |
| 64 | +} |
| 65 | + |
| 66 | +describe('renderVersionBanner (#10325)', () => { |
| 67 | + it('renders an aligned box for an ordinary semver', () => { |
| 68 | + const lines = renderVersionBanner('17.1.0'); |
| 69 | + expectAlignedBox(lines); |
| 70 | + expect(stripAnsi(lines[1])).toContain('v17.1.0'); |
| 71 | + }); |
| 72 | + |
| 73 | + it('widens the frame — never truncates — for a version longer than the historical width', () => { |
| 74 | + const long = '18.0.0-beta.1+build.20260822'; |
| 75 | + const lines = renderVersionBanner(long); |
| 76 | + expectAlignedBox(lines); |
| 77 | + // The full version string survives intact (not clipped) inside the wider box. |
| 78 | + expect(stripAnsi(lines[1])).toContain(`v${long}`); |
| 79 | + }); |
| 80 | + |
| 81 | + it('renders the same historical box width for a version no longer than the old placeholder budgeted for', () => { |
| 82 | + // "6.x" (3 chars) is one shorter than "17.1.0" (6 chars) but both sit |
| 83 | + // under the original hand-kerned budget — the box size should be |
| 84 | + // unchanged from before this fix for either. |
| 85 | + const lines = renderVersionBanner('6.x'); |
| 86 | + const plainTop = stripAnsi(lines[0]); |
| 87 | + expect([...plainTop].length).toBe(39); // ' ╔' + 35 '═' + '╗', unchanged from the pre-fix literal |
| 88 | + }); |
| 89 | + |
| 90 | + it('never renders the stale hardcoded placeholder', () => { |
| 91 | + const lines = renderVersionBanner('17.1.0').map(stripAnsi).join('\n'); |
| 92 | + expect(lines).not.toContain('v6.x'); |
| 93 | + }); |
| 94 | +}); |
| 95 | + |
| 96 | +describe('the real CLI banner (#10325, wiring)', () => { |
| 97 | + it('names the version create-objectstack\'s own package.json actually declares', () => { |
| 98 | + const declaredVersion = String( |
| 99 | + JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version, |
| 100 | + ); |
| 101 | + // Sanity: prove this is a real assertion, not one that would pass no |
| 102 | + // matter what package.json said. |
| 103 | + expect(declaredVersion).toMatch(/^\d+\.\d+\.\d+/); |
| 104 | + |
| 105 | + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'create-objectstack-banner-')); |
| 106 | + let stdout: string; |
| 107 | + try { |
| 108 | + stdout = execFileSync( |
| 109 | + TSX, |
| 110 | + [INDEX_TS, 'my-app', '--template', 'blank', '--skip-install', '--skip-skills'], |
| 111 | + { |
| 112 | + cwd: tmp, |
| 113 | + encoding: 'utf8', |
| 114 | + // Chalk decides its color level once, at import, from the process's |
| 115 | + // own env/TTY state — this run's vitest process itself sees no TTY, |
| 116 | + // so without forcing it here the child would render plain text and |
| 117 | + // the ANSI-stripping below would be exercised against a no-op, |
| 118 | + // leaving the "measure plain, not styled" requirement unverified. |
| 119 | + // FORCE_COLOR set on a *fresh child process* is read at that |
| 120 | + // process's own chalk import, unlike mutating it after the fact in |
| 121 | + // an already-running process (which chalk ignores). |
| 122 | + env: { ...process.env, FORCE_COLOR: '1' }, |
| 123 | + }, |
| 124 | + ); |
| 125 | + } finally { |
| 126 | + fs.rmSync(tmp, { recursive: true, force: true }); |
| 127 | + } |
| 128 | + |
| 129 | + // Sanity: the real ANSI codes are actually present here — otherwise the |
| 130 | + // stripAnsi() calls below would be passing through already-plain text |
| 131 | + // and this test would not be verifying the "measure plain, never |
| 132 | + // styled" property it exists to pin. |
| 133 | + expect(stdout).toContain(ESC + '['); |
| 134 | + |
| 135 | + const plain = stripAnsi(stdout); |
| 136 | + expect(plain).toContain(`◆ Create ObjectStack v${declaredVersion}`); |
| 137 | + expect(plain).not.toContain('v6.x'); |
| 138 | + |
| 139 | + // The three banner lines specifically (not the whole run's output) must |
| 140 | + // still be an aligned box in the real, wired-up output — not just in the |
| 141 | + // isolated unit tests above. |
| 142 | + const bannerLines = plain |
| 143 | + .split('\n') |
| 144 | + .filter((l) => l.includes('╔═') || l.includes('◆ Create ObjectStack') || l.includes('╚═')); |
| 145 | + expectAlignedBox(bannerLines); |
| 146 | + }, 20_000); |
| 147 | +}); |
0 commit comments