/** * generate.ts — pure spec+targets → GeneratedFile[] (never touches disk). * * The service-worker assertions pin the SACRED markers audit rule DEV-PWA-003 * greps on the CONCATENATION of src/pwa/sw.ts + src/pwa/cacheKey.ts: the * tenant/language/user cache-key derivation, the PURGE_API_CACHE purge channel, * the navigation denylist, the /api/auth/ exclusion, SKIP_WAITING, the * smartstack-api cache name, NetworkFirst 4s and ExpirationPlugin(200, 24h). * Weakening any of them in the embedded templates is a tenant/account-isolation * regression. The cross-drift test at the bottom imports SACRED_SW_MARKERS * itself, so the emitter and the auditor can never diverge silently again. */ import { describe, it, expect } from 'vitest' import { CACHE_KEY_SOURCE, DEFAULT_BACKGROUND_COLOR, DEFAULT_THEME_COLOR, generate, humaniseAppCode, resolveManifest, resolveThemeColor, swSource, SW_MESSAGES_SOURCE, type TargetSources, } from '../generate.js' import { ScaffoldPwaInputSchema, type ScaffoldPwaInput } from '../types.js' import { SACRED_SW_MARKERS } from '../../../../../audit-dev-pwa/cli/audit-dev-pwa/audit.js' function spec(overrides: Record = {}): ScaffoldPwaInput { return ScaffoldPwaInputSchema.parse({ projectPath: '/proj', appCode: 'crm', ...overrides }) } const TARGETS: TargetSources = { viteConfig: `import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [ react(), ], }) `, mainTsx: `import React from 'react'; import { createRoot } from 'react-dom/client'; import { SmartStackProvider } from '@atlashub/smartstack'; import App from './App'; import './extensions/componentRegistry.generated'; const config = { appName: 'Crm', }; createRoot(document.getElementById('root')!).render( ); `, appTsx: `import { DynamicRouter } from '@atlashub/smartstack'; export default function App() { return ; } `, indexHtml: `
`, packageJson: `{ "name": "crm-web", "dependencies": { "@atlashub/smartstack": "3.64.0" } } `, viteEnv: '/// \n', } const findFile = (files: Array<{ path: string; content: string }>, p: string) => files.find((f) => f.path === p) describe('service worker emission', () => { it('emits src/pwa/sw.ts as overwrite, delegating the cache key to ./cacheKey (DEV-PWA-003)', () => { const res = generate(spec(), TARGETS) const sw = res.files.find((f) => f.path === 'src/pwa/sw.ts')! expect(sw.strategy).toBe('overwrite') const c = sw.content // Cache-key derivation is IMPORTED, not inlined (socle a6b9f2a5 shape). expect(c).toContain("import { API_CACHE_NAME, PURGE_API_CACHE_MESSAGE, buildApiCacheKey } from './cacheKey';") expect(c).toContain('cacheKeyWillBeUsed: async ({ request }) => buildApiCacheKey(request.url, request.headers),') expect(c).toContain('cacheName: API_CACHE_NAME,') expect(c).not.toContain("const API_CACHE = 'smartstack-api';") // Purge channel — the app posts PURGE_API_CACHE on login/logout. expect(c).toContain('event.data?.type === PURGE_API_CACHE_MESSAGE') expect(c).toContain('event.waitUntil(caches.delete(API_CACHE_NAME))') // Navigation denylist — regexes byte-faithful to the socle. expect(c).toContain('denylist: [/^\\/api\\//, /^\\/hubs\\//],') // Auth exclusion + strategy tuning. expect(c).toContain("!url.pathname.startsWith('/api/auth/')") expect(c).toContain('networkTimeoutSeconds: 4') expect(c).toContain('new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 })') // Prompt-only update flow. expect(c).toContain("event.data?.type === 'SKIP_WAITING'") expect(c).toContain('self.skipWaiting()') }) it('emits src/pwa/cacheKey.ts as overwrite carrying the tenant/lang/user key derivation', () => { const res = generate(spec(), TARGETS) const f = res.files.find((x) => x.path === 'src/pwa/cacheKey.ts')! expect(f.strategy).toBe('overwrite') const c = f.content expect(c).toContain('AUTO-GENERATED') // The three response-varying dimensions, each with its fallback. expect(c).toContain("headers.get('X-Tenant-Slug') ?? 'no-tenant'") expect(c).toContain("headers.get('Accept-Language') ?? 'default'") expect(c).toContain("headers.get('X-User-Id') ?? 'anonymous'") expect(c).toContain("keyed.searchParams.set('__ss_tenant', tenant)") expect(c).toContain("keyed.searchParams.set('__ss_lang', language)") expect(c).toContain("keyed.searchParams.set('__ss_user', user)") // Cache identity + purge message contract. expect(c).toContain("export const API_CACHE_NAME = 'smartstack-api';") expect(c).toContain("export const PURGE_API_CACHE_MESSAGE = 'PURGE_API_CACHE';") expect(c).toContain('export function buildApiCacheKey(url: string, headers: HeaderReader): string') }) it('emits src/pwa/swMessages.ts as overwrite with the purgeApiCache() helper', () => { const res = generate(spec(), TARGETS) const f = res.files.find((x) => x.path === 'src/pwa/swMessages.ts')! expect(f.strategy).toBe('overwrite') const c = f.content expect(c).toContain('AUTO-GENERATED') expect(c).toContain("import { PURGE_API_CACHE_MESSAGE } from './cacheKey';") expect(c).toContain('export function purgeApiCache(): void') expect(c).toContain('navigator.serviceWorker?.controller?.postMessage({ type: PURGE_API_CACHE_MESSAGE })') }) it('never drifts from the auditor: every SACRED_SW_MARKERS entry appears in sw.ts + cacheKey.ts', () => { // DEV-PWA-003 greps these markers on the concatenation of the two emitted // files. Importing the auditor's own list makes the contract single-sourced. const concat = swSource('Crm') + '\n' + CACHE_KEY_SOURCE for (const marker of SACRED_SW_MARKERS) { expect(concat, `SACRED marker lost by the emitter: ${marker}`).toContain(marker) } }) it('carries the AUTO-GENERATED header naming frontend-pwa and the DEV-PWA-005/003 guard note', () => { const c = swSource('Crm') expect(c.startsWith('/// ')).toBe(true) expect(c).toContain('AUTO-GENERATED') expect(c).toContain('frontend-pwa') expect(c).toContain('DEV-PWA-005') expect(c).toContain('DEV-PWA-003') }) it('substitutes the push fallback title with the manifest name (the ONE allowed deviation)', () => { const res = generate(spec(), TARGETS) const c = findFile(res.files, 'src/pwa/sw.ts')!.content expect(c).toContain("payload.title ?? 'Crm'") expect(c).not.toContain("?? 'SmartStack'") }) it('escapes quotes in the manifest name inside the sw template', () => { const c = swSource("L'Atelier") expect(c).toContain("payload.title ?? 'L\\'Atelier'") }) }) describe('icons', () => { it('emits the 4 placeholder icons as base64 skip-if-exists', () => { const res = generate(spec(), TARGETS) for (const p of [ 'public/icons/pwa-192.png', 'public/icons/pwa-512.png', 'public/icons/pwa-512-maskable.png', 'public/icons/apple-touch-icon.png', ]) { const f = findFile(res.files, p)! expect(f.encoding).toBe('base64') expect(f.strategy).toBe('skip-if-exists') // Valid base64 that decodes to a PNG. const buf = Buffer.from(f.content, 'base64') expect([...buf.subarray(0, 4)]).toEqual([0x89, 0x50, 0x4e, 0x47]) } }) it("emits no icons with icons: 'skip'", () => { const res = generate(spec({ icons: 'skip' }), TARGETS) expect(res.files.some((f) => f.path.startsWith('public/icons/'))).toBe(false) }) }) describe('theme color resolution', () => { it('spec.manifest.themeColor wins', () => { expect(resolveThemeColor(spec({ manifest: { themeColor: '#ff0000' } }), ':root { --sl-color-primary-500: #22c55e; }')).toBe('#ff0000') }) /** The accent ramp scaffold-theme emits on EVERY generated app (no Shoelace). */ const PLATFORM_CSS = `:root { --color-accent-300: #7dabf8; --color-accent-400: #5c96f6; --color-accent-500: #3b82f6; --color-accent-600: #326fd1; --color-accent-700: #295bac; } ` it('reads the platform accent ramp — the token every generated app carries', () => { // Regression: probing --sl-color-primary-500 first made every non-Shoelace // app (the default) fall through to the generic indigo default. expect(resolveThemeColor(spec(), PLATFORM_CSS)).toBe('#326fd1') }) it('prefers accent-600 over accent-500 (status-bar contrast)', () => { expect(resolveThemeColor(spec(), PLATFORM_CSS)).toBe('#326fd1') const only500 = ':root { --color-accent-500: #3b82f6; }' expect(resolveThemeColor(spec(), only500)).toBe('#3b82f6') }) it('still reads the Shoelace primary token when no platform accent exists', () => { expect(resolveThemeColor(spec(), ':root {\n --sl-color-primary-500: #22C55E;\n}')).toBe('#22c55e') }) it('the platform accent wins over a Shoelace token in the same file', () => { const both = PLATFORM_CSS + ':root { --sl-color-primary-500: #22c55e; }' expect(resolveThemeColor(spec(), both)).toBe('#326fd1') }) it('spec.manifest.themeColor still wins over every token', () => { expect(resolveThemeColor(spec({ manifest: { themeColor: '#ff0000' } }), PLATFORM_CSS)).toBe('#ff0000') }) it('expands a 3-digit hex and skips a token aliased to another var()', () => { expect(resolveThemeColor(spec(), ':root { --color-accent-600: #FA0; }')).toBe('#ffaa00') const aliased = ':root {\n --color-accent-600: var(--brand);\n --color-accent-500: #3b82f6;\n}' expect(resolveThemeColor(spec(), aliased)).toBe('#3b82f6') }) it('gracefully falls through to the socle default', () => { expect(resolveThemeColor(spec(), ':root { color: red }')).toBe(DEFAULT_THEME_COLOR) expect(resolveThemeColor(spec(), undefined)).toBe(DEFAULT_THEME_COLOR) }) it('backgroundColor defaults to the socle dark background', () => { expect(resolveManifest(spec()).backgroundColor).toBe(DEFAULT_BACKGROUND_COLOR) }) }) describe('manifest resolution', () => { it('humanises the appCode into the manifest name', () => { expect(humaniseAppCode('crm-app')).toBe('Crm App') expect(resolveManifest(spec()).name).toBe('Crm') }) it('shortName defaults to the name truncated to 12 chars', () => { const m = resolveManifest(spec({ manifest: { name: 'A Very Long Application Name' } })) expect(m.shortName.length).toBeLessThanOrEqual(12) expect(m.shortName).toBe('A Very Long') }) }) describe('OutboxStatusChip', () => { it('is emitted skip-if-exists with the exact scaffold-component props contract', () => { const res = generate(spec(), TARGETS) const chip = findFile(res.files, 'src/components/pwa/OutboxStatusChip.tsx')! expect(chip.strategy).toBe('skip-if-exists') const c = chip.content // Props: { resourceKey?, labels?: { pending, failed, conflict } }. expect(c).toContain('resourceKey?: string') expect(c).toContain('labels?: OutboxStatusChipLabels') expect(c).toContain('pending: string') expect(c).toContain('failed: string') expect(c).toContain('conflict: string') // Built on the REAL package handle. expect(c).toContain("import { useOutboxStatus } from '@atlashub/smartstack'") // Resource scoping: exact key OR sub-key. expect(c).toContain("r.type === resourceKey || r.type.startsWith(resourceKey + '.')") // Renders nothing when the scoped queue is empty. expect(c).toContain('if (pending + failed + conflict === 0) return null') // Retry-all affordance via the handle. expect(c).toContain('outbox.retryAll()') expect(c).toContain('outbox.retry(r.id)') // Token-styled, no hardcoded colors. expect(c).toContain('var(--warning-text)') expect(c).not.toMatch(/#[0-9a-fA-F]{6}/) }) }) describe('patch routing', () => { it('emits the 6 patch files on a virgin client app', () => { const res = generate(spec(), TARGETS) const patches = res.files.filter((f) => f.strategy === 'patch').map((f) => f.path).sort() expect(patches).toEqual([ 'index.html', 'package.json', 'src/App.tsx', 'src/main.tsx', 'src/vite-env.d.ts', 'vite.config.ts', ]) }) it('emits NO patch files when the targets are already patched (fixpoint at generate level)', () => { const s = spec() const first = generate(s, TARGETS) const patched: TargetSources = { ...TARGETS } for (const f of first.files) { if (f.strategy !== 'patch') continue if (f.path === 'vite.config.ts') patched.viteConfig = f.content if (f.path === 'src/main.tsx') patched.mainTsx = f.content if (f.path === 'src/App.tsx') patched.appTsx = f.content if (f.path === 'index.html') patched.indexHtml = f.content if (f.path === 'package.json') patched.packageJson = f.content if (f.path === 'src/vite-env.d.ts') patched.viteEnv = f.content } const second = generate(s, patched) expect(second.files.filter((f) => f.strategy === 'patch')).toEqual([]) expect(second.warnings).toEqual([]) }) it('falls back to a full manual snippet when the vite.config anchor is missing', () => { const res = generate(spec(), { ...TARGETS, viteConfig: 'export default {}\n' }) expect(res.warnings.some((w) => w.includes('vite.config.ts'))).toBe(true) expect(res.manualSnippets.some((s) => s.includes('VitePWA({'))).toBe(true) expect(res.files.some((f) => f.path === 'vite.config.ts')).toBe(false) }) it('warns (with snippets where applicable) when target files are missing', () => { const res = generate(spec(), {}) expect(res.warnings.some((w) => w.includes('vite.config.ts'))).toBe(true) expect(res.warnings.some((w) => w.includes('src/main.tsx'))).toBe(true) expect(res.warnings.some((w) => w.includes('src/App.tsx'))).toBe(true) expect(res.warnings.some((w) => w.includes('index.html'))).toBe(true) expect(res.warnings.some((w) => w.includes('package.json'))).toBe(true) expect(res.manualSnippets.length).toBeGreaterThanOrEqual(2) // vite-env is still seeded fresh (with both references). const env = findFile(res.files, 'src/vite-env.d.ts')! expect(env.content).toContain('vite/client') expect(env.content).toContain('vite-plugin-pwa/client') }) }) describe('vite config manifest content', () => { it('routes the resolved colors + name into the VitePWA manifest', () => { const res = generate( spec({ manifest: { name: 'Crm App', themeColor: '#ff0000', backgroundColor: '#00ff00' } }), TARGETS, ) const vite = findFile(res.files, 'vite.config.ts')!.content expect(vite).toContain("name: 'Crm App'") expect(vite).toContain("theme_color: '#ff0000'") expect(vite).toContain("background_color: '#00ff00'") const html = findFile(res.files, 'index.html')!.content expect(html).toContain('content="#ff0000"') expect(html).toContain('content="#00ff00"') }) })