|
| 1 | +/** |
| 2 | + * Packed-artifact consumer qualification for @openelement/ui (#1301). |
| 3 | + * |
| 4 | + * The observational rule for packaging defects: qualify the PACKED artifact, |
| 5 | + * never the workspace source. This tool installs the five pack:dry-run |
| 6 | + * tarballs into a scratch consumer OUTSIDE the repository (so the adapter's |
| 7 | + * workspace auto-alias in workspace-alias.ts cannot substitute workspace |
| 8 | + * source for the packed modules), builds a minimal app that admits |
| 9 | + * @openelement/ui through the documented `packageIslands` path, and asserts |
| 10 | + * the prerendered HTML carries the compiled DSD for <open-theme-toggle>. |
| 11 | + * |
| 12 | + * Pre-#1301 the packed ui modules lost their compile-time-only |
| 13 | + * @element/@property intrinsics to `deno pack` transpilation, no Part |
| 14 | + * Program registered, and the static prerender failed closed with |
| 15 | + * OE_PROGRAM_MISSING (route / -> 500, build error). Post-fix the packed |
| 16 | + * modules carry the compiler output and the build succeeds. |
| 17 | + */ |
| 18 | + |
| 19 | +import { existsSync } from '@std/fs'; |
| 20 | +import { join, resolve } from '@std/path'; |
| 21 | +import { formatJson } from '@openelement/element/build-utils'; |
| 22 | +import { PACKAGE_VERSION, RETAINED_PACKAGE_NAMES } from './project-constants.ts'; |
| 23 | +import { readPackages } from './lib/package-graph.ts'; |
| 24 | +import { tarballPath } from './lib/npm-tarball.ts'; |
| 25 | + |
| 26 | +const repoRoot = resolve(import.meta.dirname!, '..'); |
| 27 | +// Generous ceiling for the real SSG build; a hung packed adapter must fail |
| 28 | +// the tool instead of stalling CI forever (same contract as |
| 29 | +// consumer-packaged-starter.ts). |
| 30 | +const BUILD_TIMEOUT_MS = 10 * 60_000; |
| 31 | + |
| 32 | +async function run( |
| 33 | + command: string, |
| 34 | + args: string[], |
| 35 | + cwd: string, |
| 36 | + timeoutMs?: number, |
| 37 | +): Promise<{ success: boolean; output: string }> { |
| 38 | + const controller = new AbortController(); |
| 39 | + let timedOut = false; |
| 40 | + const timeoutId = timeoutMs === undefined ? undefined : setTimeout(() => { |
| 41 | + timedOut = true; |
| 42 | + controller.abort(); |
| 43 | + }, timeoutMs); |
| 44 | + try { |
| 45 | + const result = await new Deno.Command(command, { |
| 46 | + args, |
| 47 | + cwd, |
| 48 | + stdout: 'piped', |
| 49 | + stderr: 'piped', |
| 50 | + ...(timeoutMs === undefined ? {} : { signal: controller.signal }), |
| 51 | + }).output(); |
| 52 | + const decoder = new TextDecoder(); |
| 53 | + const output = decoder.decode(result.stdout) + decoder.decode(result.stderr); |
| 54 | + if (timedOut) { |
| 55 | + return { |
| 56 | + success: false, |
| 57 | + output: `Timed out after ${timeoutMs}ms: ${command} ${args.join(' ')}\n${output}`, |
| 58 | + }; |
| 59 | + } |
| 60 | + return { success: result.success, output }; |
| 61 | + } finally { |
| 62 | + clearTimeout(timeoutId); |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +function assertIncludes(haystack: string, needle: string, label: string): void { |
| 67 | + if (!haystack.includes(needle)) { |
| 68 | + throw new Error(`Packed consumer assertion failed (${label}): missing ${needle}`); |
| 69 | + } |
| 70 | +} |
| 71 | + |
| 72 | +const CONSUMER_DENO_JSON = { |
| 73 | + imports: { |
| 74 | + '@openelement/app': `npm:@openelement/app@${PACKAGE_VERSION}`, |
| 75 | + '@openelement/adapter-vite': `npm:@openelement/adapter-vite@${PACKAGE_VERSION}`, |
| 76 | + '@openelement/element': `npm:@openelement/element@${PACKAGE_VERSION}`, |
| 77 | + '@openelement/element/jsx-runtime': `npm:@openelement/element@${PACKAGE_VERSION}/jsx-runtime`, |
| 78 | + '@openelement/element/jsx-dev-runtime': |
| 79 | + `npm:@openelement/element@${PACKAGE_VERSION}/jsx-dev-runtime`, |
| 80 | + '@openelement/ui': `npm:@openelement/ui@${PACKAGE_VERSION}`, |
| 81 | + '@openelement/ui/open-theme-toggle': `npm:@openelement/ui@${PACKAGE_VERSION}/open-theme-toggle`, |
| 82 | + 'hono': 'npm:hono@^4.12', |
| 83 | + 'vite': 'npm:vite@8.0.16', |
| 84 | + }, |
| 85 | + nodeModulesDir: 'manual', |
| 86 | + minimumDependencyAge: 0, |
| 87 | + tasks: { |
| 88 | + build: |
| 89 | + `deno run --config deno.json -A npm:@openelement/adapter-vite@${PACKAGE_VERSION}/cli/build`, |
| 90 | + }, |
| 91 | + compilerOptions: { |
| 92 | + lib: ['ES2022', 'DOM', 'DOM.Iterable'], |
| 93 | + jsx: 'react-jsx', |
| 94 | + jsxImportSource: '@openelement/element', |
| 95 | + }, |
| 96 | +}; |
| 97 | + |
| 98 | +const CONSUMER_VITE_CONFIG = `import { openElement } from '@openelement/adapter-vite'; |
| 99 | +import { defineConfig } from 'vite'; |
| 100 | +
|
| 101 | +export default defineConfig({ |
| 102 | + base: '/', |
| 103 | + esbuild: { |
| 104 | + jsx: 'automatic', |
| 105 | + jsxImportSource: '@openelement/element', |
| 106 | + }, |
| 107 | + plugins: [ |
| 108 | + ...openElement({ |
| 109 | + routesDir: 'app/routes', |
| 110 | + appShell: false, |
| 111 | + // The documented packed-artifact admission path under test (#1301). |
| 112 | + packageIslands: ['@openelement/ui'], |
| 113 | + ssr: { |
| 114 | + noExternal: ['@openelement/ui'], |
| 115 | + }, |
| 116 | + html: { |
| 117 | + title: 'packed ui consumer', |
| 118 | + }, |
| 119 | + }), |
| 120 | + ], |
| 121 | +}); |
| 122 | +`; |
| 123 | + |
| 124 | +const CONSUMER_ROUTE = `import { definePage } from '@openelement/app'; |
| 125 | +import HomePage from '../components/page-home.tsx'; |
| 126 | +
|
| 127 | +export default definePage(HomePage, { |
| 128 | + head: { title: 'packed ui consumer — home' }, |
| 129 | +}); |
| 130 | +`; |
| 131 | + |
| 132 | +const CONSUMER_PAGE = `import { element, OpenElement } from '@openelement/element'; |
| 133 | +import '@openelement/ui/open-theme-toggle'; |
| 134 | +
|
| 135 | +@element('index-page', { root: 'shadow-open' }) |
| 136 | +export default class HomePage extends OpenElement { |
| 137 | + render() { |
| 138 | + return ( |
| 139 | + <main> |
| 140 | + <h1 id='home-marker'>packed ui consumer home</h1> |
| 141 | + <open-theme-toggle theme='light'></open-theme-toggle> |
| 142 | + </main> |
| 143 | + ); |
| 144 | + } |
| 145 | +} |
| 146 | +`; |
| 147 | + |
| 148 | +const tmp = await Deno.makeTempDir({ prefix: 'openelement-packaged-ui-' }); |
| 149 | +try { |
| 150 | + const workspacePackages = await readPackages(); |
| 151 | + const tarballs = RETAINED_PACKAGE_NAMES.map((name) => { |
| 152 | + const pkg = workspacePackages.find((candidate) => candidate.name === name); |
| 153 | + if (!pkg) throw new Error(`Retained package missing from workspace graph: ${name}`); |
| 154 | + return { name, path: join(repoRoot, tarballPath(pkg)) }; |
| 155 | + }); |
| 156 | + for (const tarball of tarballs) { |
| 157 | + if (!existsSync(tarball.path)) { |
| 158 | + throw new Error( |
| 159 | + `Missing packed release artifact: ${tarball.path} (run \`deno task pack:dry-run\` first)`, |
| 160 | + ); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + // @jsr/* packages are served by JSR's npm compatibility layer (see |
| 165 | + // consumer-packaged-starter.ts, #886). |
| 166 | + Deno.writeTextFileSync(join(tmp, '.npmrc'), '@jsr:registry=https://npm.jsr.io\n'); |
| 167 | + |
| 168 | + // An explicit package.json with file: deps keeps npm from walking ancestor |
| 169 | + // directories and from pruning the external deps on tarball re-install. |
| 170 | + const dependencies: Record<string, string> = { |
| 171 | + 'vite': '8.0.16', |
| 172 | + 'hono': '4.12.0', |
| 173 | + }; |
| 174 | + for (const tarball of tarballs) dependencies[tarball.name] = `file:${tarball.path}`; |
| 175 | + Deno.writeTextFileSync( |
| 176 | + join(tmp, 'package.json'), |
| 177 | + formatJson({ |
| 178 | + name: 'openelement-packed-ui-consumer', |
| 179 | + private: true, |
| 180 | + type: 'module', |
| 181 | + dependencies, |
| 182 | + }), |
| 183 | + ); |
| 184 | + |
| 185 | + const install = await run( |
| 186 | + 'npm', |
| 187 | + ['install', '--ignore-scripts', '--no-audit', '--no-fund'], |
| 188 | + tmp, |
| 189 | + ); |
| 190 | + if (!install.success) throw new Error(`Packed package installation failed:\n${install.output}`); |
| 191 | + |
| 192 | + // The packed tarballs only cover @openelement/*; vite/hono resolve from the |
| 193 | + // registry. npm lays the tarball contents into node_modules directly, so no |
| 194 | + // repo node_modules scavenging happens here — the consumer is hermetic. |
| 195 | + |
| 196 | + Deno.writeTextFileSync(join(tmp, 'deno.json'), formatJson(CONSUMER_DENO_JSON)); |
| 197 | + Deno.writeTextFileSync(join(tmp, 'vite.config.ts'), CONSUMER_VITE_CONFIG); |
| 198 | + Deno.mkdirSync(join(tmp, 'app', 'routes'), { recursive: true }); |
| 199 | + Deno.mkdirSync(join(tmp, 'app', 'components'), { recursive: true }); |
| 200 | + Deno.writeTextFileSync(join(tmp, 'app', 'routes', 'index.tsx'), CONSUMER_ROUTE); |
| 201 | + Deno.writeTextFileSync(join(tmp, 'app', 'components', 'page-home.tsx'), CONSUMER_PAGE); |
| 202 | + |
| 203 | + const build = await run(Deno.execPath(), ['task', 'build'], tmp, BUILD_TIMEOUT_MS); |
| 204 | + if (!build.success) { |
| 205 | + throw new Error(`Packed ui consumer SSG build failed:\n${build.output}`); |
| 206 | + } |
| 207 | + |
| 208 | + // A green exit alone is not enough: the prerendered page must carry the |
| 209 | + // compiled DSD for the packed island — the host tag, its declarative shadow |
| 210 | + // template, the compiled static markup and the compiled data-theme sink. |
| 211 | + const indexHtmlPath = join(tmp, 'dist', 'index.html'); |
| 212 | + if (!existsSync(indexHtmlPath)) { |
| 213 | + throw new Error(`Packed ui consumer build emitted no prerendered page: ${indexHtmlPath}`); |
| 214 | + } |
| 215 | + const html = Deno.readTextFileSync(indexHtmlPath); |
| 216 | + assertIncludes(html, '<open-theme-toggle theme="light">', 'island host tag'); |
| 217 | + assertIncludes(html, '<template shadowrootmode="open"', 'island DSD template'); |
| 218 | + assertIncludes(html, 'class="theme-toggle"', 'compiled island markup'); |
| 219 | + assertIncludes(html, 'data-theme="light"', 'compiled property sink'); |
| 220 | + |
| 221 | + console.log( |
| 222 | + `Packed @openelement/ui consumer qualification passed for ${PACKAGE_VERSION}: ` + |
| 223 | + 'packageIslands SSR admission renders the compiled DSD from the packed artifact.', |
| 224 | + ); |
| 225 | +} finally { |
| 226 | + await Deno.remove(tmp, { recursive: true }).catch(() => undefined); |
| 227 | +} |
0 commit comments