/** * patchers.ts — pure string→string patchers, every one an idempotent fixpoint. * * The double-apply assertions are the load-bearing ones: scaffold-pwa may be * re-run at any time (ba-develop re-entry, manual re-run after an upgrade) and * a non-idempotent patcher would duplicate imports/blocks on every pass. */ import { describe, it, expect } from 'vitest' import { findProviderConfigAnchor, isCustomised, mergePackageJson, patchAppTsx, patchIndexHtml, patchMainTsx, patchViteConfig, patchViteEnv, PWA_DEV_DEPENDENCIES, vitePwaSnippet, type ManifestConfig, } from '../patchers.js' const MANIFEST: ManifestConfig = { name: 'Crm App', shortName: 'Crm App', description: 'Crm App — SmartStack progressive web app', themeColor: '#4f46e5', backgroundColor: '#111827', } // ── Fixtures: the generated-client shapes (frontend/structure SKILL.md) ───── const VITE_CONFIG = `import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { plugins: [ react(), tailwindcss(), ], resolve: { alias: { '@': path.resolve(__dirname, './src') } }, } }) ` const MAIN_TSX = `import React from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; import App from './App'; import './index.css'; import './extensions/componentRegistry.generated'; const config = { apiUrl: import.meta.env.VITE_API_URL ?? 'http://localhost:5142', appName: 'Crm', }; createRoot(document.getElementById('root')!).render( ); ` /** VERBATIM `ss init` output (src/commands/init.ts): the provider config is an * INLINE JSX literal, not a hoisted `const config`. This is the shape every * standard client app carries — the mobile patcher must anchor on it. */ const MAIN_TSX_INLINE = `import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; import './extensions/componentRegistry.generated'; import App from './App'; import './index.css'; import './i18n'; createRoot(document.getElementById('root')!).render( , ); ` const APP_TSX = `import { DynamicRouter } from '@atlashub/smartstack'; export default function App() { return ; } ` const INDEX_HTML = ` Crm
` const PACKAGE_JSON = `{ "name": "crm-web", "dependencies": { "@atlashub/smartstack": "3.64.0", "react": "^19.0.0" }, "devDependencies": { "vite": "^7.0.0" } } ` // ════════════════════════════════════════════════════════════════════════════ describe('patchViteConfig', () => { it('inserts the VitePWA import after the last import and the plugin into plugins: [', () => { const r = patchViteConfig(VITE_CONFIG, MANIFEST) expect(r.changed).toBe(true) expect(r.warning).toBeUndefined() const importIdx = r.content.indexOf("import { VitePWA } from 'vite-plugin-pwa'") const lastOtherImport = r.content.indexOf("import path from 'path'") expect(importIdx).toBeGreaterThan(lastOtherImport) // Plugin entry lands right inside the plugins array, before react(). expect(r.content.indexOf('VitePWA({')).toBeLessThan(r.content.indexOf('react(),')) // Socle-faithful settings. expect(r.content).toContain("strategies: 'injectManifest'") expect(r.content).toContain("srcDir: 'src/pwa'") expect(r.content).toContain("filename: 'sw.ts'") expect(r.content).toContain("registerType: 'prompt'") expect(r.content).toContain('injectRegister: false') expect(r.content).toContain("globPatterns: ['**/*.{js,css,html,svg,png,woff2}']") expect(r.content).toContain('maximumFileSizeToCacheInBytes: 2.5 * 1024 * 1024') expect(r.content).toContain('devOptions: { enabled: false }') // Why-comments carried over. expect(r.content).toContain('never autoUpdate') expect(r.content).toContain('iOS Cache API quota') // Manifest values. expect(r.content).toContain("name: 'Crm App'") expect(r.content).toContain("theme_color: '#4f46e5'") expect(r.content).toContain("background_color: '#111827'") }) it('is idempotent — a second apply is a fixpoint', () => { const once = patchViteConfig(VITE_CONFIG, MANIFEST) const twice = patchViteConfig(once.content, MANIFEST) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) it('leaves a file already carrying VitePWA( untouched', () => { const src = VITE_CONFIG.replace('react(),', 'react(),\n VitePWA({}),') const r = patchViteConfig(src, MANIFEST) expect(r.changed).toBe(false) expect(r.content).toBe(src) }) it('skips a @customised file with a warning', () => { const src = '// @customised\n' + VITE_CONFIG const r = patchViteConfig(src, MANIFEST) expect(r.changed).toBe(false) expect(r.warning).toMatch(/@customised/) }) it('warns and returns the source unchanged when the plugins anchor is missing', () => { const src = "import { defineConfig } from 'vite'\nexport default defineConfig({})\n" const r = patchViteConfig(src, MANIFEST) expect(r.changed).toBe(false) expect(r.content).toBe(src) expect(r.warning).toMatch(/plugins/) }) it('escapes single quotes in manifest strings', () => { const m = { ...MANIFEST, description: "plateforme d'entreprise" } const snippet = vitePwaSnippet(m) expect(snippet).toContain("description: 'plateforme d\\'entreprise'") }) }) describe('patchMainTsx', () => { const OPTS = { mobile: { enabled: true as const, breakpoint: 768 } } it('wires the outbox import, the registerSW bridge, initOutbox and the mobile config', () => { const r = patchMainTsx(MAIN_TSX, OPTS) expect(r.changed).toBe(true) expect(r.warning).toBeUndefined() const c = r.content // outbox.generated import lands after the LAST ./extensions/* import. expect(c.indexOf("import './extensions/outbox.generated'")).toBeGreaterThan( c.indexOf("import './extensions/componentRegistry.generated'"), ) // registerSW bridge (socle main.tsx shape). expect(c).toContain("import { registerSW } from 'virtual:pwa-register'") expect(c).toContain( "import { setServiceWorkerUpdateState, setServiceWorkerUpdater, initOutbox } from '@atlashub/smartstack'", ) expect(c).toContain('onNeedRefresh: () => setServiceWorkerUpdateState({ needRefresh: true })') expect(c).toContain('onOfflineReady: () => setServiceWorkerUpdateState({ offlineReady: true })') expect(c).toContain('setServiceWorkerUpdater(updateSW)') // The whole block runs before createRoot. expect(c.indexOf('initOutbox()')).toBeLessThan(c.indexOf('createRoot(')) // Mobile shell config with the breakpoint. expect(c).toContain('mobile: { enabled: true, breakpoint: 768 },') }) it("keeps initOutbox() textually AFTER the outbox.generated import (DEV-PWA-008 order)", () => { const c = patchMainTsx(MAIN_TSX, OPTS).content expect(c.indexOf("import './extensions/outbox.generated'")).toBeLessThan(c.indexOf('initOutbox()')) }) it('is idempotent — a second apply is a fixpoint', () => { const once = patchMainTsx(MAIN_TSX, OPTS) const twice = patchMainTsx(once.content, OPTS) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) it('omits the breakpoint when unspecified and skips mobile when disabled', () => { const noBp = patchMainTsx(MAIN_TSX, { mobile: { enabled: true } }) expect(noBp.content).toContain('mobile: { enabled: true },') const disabled = patchMainTsx(MAIN_TSX, { mobile: { enabled: false } }) expect(disabled.content).not.toContain('mobile: {') }) it('never duplicates an existing mobile: key in the config', () => { const src = MAIN_TSX.replace("appName: 'Crm',", "appName: 'Crm',\n mobile: { enabled: false },") const r = patchMainTsx(src, OPTS) expect(r.content.match(/mobile\s*:/g)).toHaveLength(1) }) it('skips a @customised main.tsx with a warning', () => { const src = '// @customised\n' + MAIN_TSX const r = patchMainTsx(src, OPTS) expect(r.changed).toBe(false) expect(r.content).toBe(src) expect(r.warning).toMatch(/@customised/) }) it('warns when createRoot cannot be anchored', () => { const src = "import x from 'y'\nconsole.log(x)\n" const r = patchMainTsx(src, { mobile: { enabled: false } }) expect(r.warning).toMatch(/createRoot/) expect(r.content).not.toContain('registerSW(') }) // ── The `ss init` shape: an INLINE `config={{ … }}` JSX literal ─────────── // Anchoring only on `const config = {` silently skipped every standard app, // so `mobile: { enabled: true }` was never written and the shell stayed off. it('injects the mobile config into the inline `config={{` literal (ss init shape)', () => { const r = patchMainTsx(MAIN_TSX_INLINE, OPTS) expect(r.changed).toBe(true) expect(r.warning).toBeUndefined() expect(r.content).toContain('mobile: { enabled: true, breakpoint: 768 },') // Inside the provider config, not dropped at top level. const cfgAt = r.content.indexOf('config={{') const mobileAt = r.content.indexOf('mobile: { enabled: true') const closeAt = r.content.indexOf('}}', cfgAt) expect(mobileAt).toBeGreaterThan(cfgAt) expect(mobileAt).toBeLessThan(closeAt) // Members keep the literal's own indentation (anchor line + one level). expect(r.content).toContain('\n mobile: { enabled: true, breakpoint: 768 },') // The pre-existing members survive. expect(r.content).toContain('extensions: {}') expect(r.content).toContain("apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:5142'") }) it('is idempotent on the inline form — a second apply is a fixpoint', () => { const once = patchMainTsx(MAIN_TSX_INLINE, OPTS) const twice = patchMainTsx(once.content, OPTS) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) expect(once.content.match(/mobile\s*:/g)).toHaveLength(1) }) it('skips a @customised inline main.tsx with a warning', () => { const src = '// @customised\n' + MAIN_TSX_INLINE const r = patchMainTsx(src, OPTS) expect(r.changed).toBe(false) expect(r.content).toBe(src) expect(r.warning).toMatch(/@customised/) }) it('never duplicates an existing mobile: key in the inline form', () => { const src = MAIN_TSX_INLINE.replace('extensions: {}', 'extensions: {},\n mobile: { enabled: false }') const r = patchMainTsx(src, OPTS) expect(r.content.match(/mobile\s*:/g)).toHaveLength(1) expect(r.content).toContain('mobile: { enabled: false }') }) it('warns when neither config form is present', () => { const src = MAIN_TSX_INLINE.replace(/config=\{\{[\s\S]*?\}\}/, 'config={cfg}') const r = patchMainTsx(src, OPTS) expect(r.warning).toMatch(/config=\{\{/) expect(r.content).not.toContain('mobile:') }) }) describe('findProviderConfigAnchor', () => { it('prefers the inline JSX literal (bound to the provider) over a same-named local', () => { const src = MAIN_TSX_INLINE.replace( "import './i18n';", "import './i18n';\n\nconst config = { unrelated: true };", ) const a = findProviderConfigAnchor(src) expect(a.kind).toBe('jsx') const injected = patchMainTsx(src, { mobile: { enabled: true } }).content expect(injected).toContain('extensions: {}') expect(injected.indexOf('mobile:')).toBeGreaterThan(injected.indexOf('config={{')) expect(injected).toContain('const config = { unrelated: true };') }) it('falls back to the hoisted `const config = {` form', () => { const a = findProviderConfigAnchor(MAIN_TSX) expect(a.kind).toBe('const') }) it('reports `none` when no config literal exists', () => { expect(findProviderConfigAnchor('const x = 1\n').kind).toBe('none') }) it('falls through to a usable candidate when the first one never closes', () => { const src = "const config = {\n apiUrl: 'x',\n};\nrender(

{ it('wraps in a fragment with UpdateBanner + InstallBanner and extends the import', () => { const r = patchAppTsx(APP_TSX) expect(r.changed).toBe(true) expect(r.content).toContain('') expect(r.content).toContain('') expect(r.content).toContain('') expect(r.content).toContain( "import { DynamicRouter, UpdateBanner, InstallBanner } from '@atlashub/smartstack'", ) // Fragment-wrapped, not stacked roots. expect(r.content).toContain('<>') expect(r.content).toContain('') }) it('is idempotent — a second apply is a fixpoint', () => { const once = patchAppTsx(APP_TSX) const twice = patchAppTsx(once.content) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) it('adds a dedicated import line when no @atlashub/smartstack import exists', () => { const src = "import { Something } from 'elsewhere';\n\nexport default function App() {\n return ;\n}\n" const r = patchAppTsx(src) expect(r.content).toContain("import { UpdateBanner, InstallBanner } from '@atlashub/smartstack'") }) it('warns when is not found', () => { const src = 'export default function App() {\n return

;\n}\n' const r = patchAppTsx(src) expect(r.changed).toBe(false) expect(r.warning).toMatch(/DynamicRouter/) }) it('skips a @customised App.tsx with a warning', () => { const src = '// @customised\n' + APP_TSX const r = patchAppTsx(src) expect(r.changed).toBe(false) expect(r.warning).toMatch(/@customised/) }) }) describe('patchIndexHtml', () => { it('appends viewport-fit=cover and inserts the theme-color/apple tags before ', () => { const r = patchIndexHtml(INDEX_HTML, MANIFEST) expect(r.changed).toBe(true) const c = r.content expect(c).toContain('content="width=device-width, initial-scale=1.0, viewport-fit=cover"') expect(c).toContain('') expect(c).toContain('') expect(c).toContain('') expect(c).toContain('') expect(c).toContain('') expect(c).toContain('') expect(c).toContain('') // Everything landed inside . expect(c.indexOf('rel="apple-touch-icon"')).toBeLessThan(c.indexOf('')) }) it('is idempotent — a second apply is a fixpoint', () => { const once = patchIndexHtml(INDEX_HTML, MANIFEST) const twice = patchIndexHtml(once.content, MANIFEST) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) it('is per-tag idempotent — a pre-existing apple-touch-icon is not duplicated', () => { const src = INDEX_HTML.replace( '', ' \n ', ) const r = patchIndexHtml(src, MANIFEST) expect(r.content.match(/rel="apple-touch-icon"/g)).toHaveLength(1) expect(r.content).toContain('/custom.png') }) it('warns when is missing', () => { const r = patchIndexHtml('', MANIFEST) expect(r.changed).toBe(false) expect(r.warning).toMatch(/head/) }) }) describe('mergePackageJson', () => { it('adds the missing PWA devDependencies at the socle-pinned versions', () => { const r = mergePackageJson(PACKAGE_JSON) expect(r.changed).toBe(true) const pkg = JSON.parse(r.content) expect(pkg.devDependencies['vite-plugin-pwa']).toBe('^1.3.0') for (const wb of ['workbox-core', 'workbox-expiration', 'workbox-precaching', 'workbox-routing', 'workbox-strategies']) { expect(pkg.devDependencies[wb]).toBe('^7.4.1') } // Untouched neighbours. expect(pkg.devDependencies.vite).toBe('^7.0.0') expect(pkg.dependencies.react).toBe('^19.0.0') }) it('never downgrades an existing entry (either section)', () => { const src = JSON.stringify( { name: 'x', dependencies: { 'workbox-core': '^9.0.0' }, devDependencies: { 'vite-plugin-pwa': '^2.0.0' }, }, null, 2, ) const r = mergePackageJson(src) const pkg = JSON.parse(r.content) expect(pkg.dependencies['workbox-core']).toBe('^9.0.0') expect(pkg.devDependencies['vite-plugin-pwa']).toBe('^2.0.0') // workbox-core lives in dependencies — it must NOT be re-added to devDependencies. expect(pkg.devDependencies['workbox-core']).toBeUndefined() }) it('is idempotent and preserves the 2-space indent + trailing newline', () => { const once = mergePackageJson(PACKAGE_JSON) expect(once.content.endsWith('\n')).toBe(true) expect(once.content).toContain(' "devDependencies": {') const twice = mergePackageJson(once.content) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) it('warns on invalid JSON and leaves the source untouched', () => { const r = mergePackageJson('{ not json') expect(r.changed).toBe(false) expect(r.warning).toMatch(/JSON/) }) it('the dependency map matches the socle package.json pins', () => { expect(PWA_DEV_DEPENDENCIES).toEqual({ 'vite-plugin-pwa': '^1.3.0', 'workbox-core': '^7.4.1', 'workbox-expiration': '^7.4.1', 'workbox-precaching': '^7.4.1', 'workbox-routing': '^7.4.1', 'workbox-strategies': '^7.4.1', }) }) }) describe('patchViteEnv', () => { it('appends the vite-plugin-pwa/client reference', () => { const r = patchViteEnv('/// \n') expect(r.changed).toBe(true) expect(r.content).toBe( '/// \n/// \n', ) }) it('is idempotent', () => { const once = patchViteEnv('/// \n') const twice = patchViteEnv(once.content) expect(twice.changed).toBe(false) expect(twice.content).toBe(once.content) }) }) describe('isCustomised', () => { it('recognises the three head-marker forms', () => { expect(isCustomised('// @customised\nx')).toBe(true) expect(isCustomised('/* @customised */\nx')).toBe(true) expect(isCustomised('\n')).toBe(true) expect(isCustomised('const x = 1\n// @customised later')).toBe(false) }) })