/**
* 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)
})
})