/** * validate.ts — the fail-closed gates, each driven by a temp-dir fixture: * Zod → web root → frontend mode (client only) → package gate (version floor * + capability probe, probe wins). */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { compareVersions, probePackageCapabilities, validate } from '../validate.js' import { MIN_SMARTSTACK_PWA_VERSION } from '../types.js' let root: string beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'pwa-validate-')) }) afterEach(() => { fs.rmSync(root, { recursive: true, force: true }) }) function write(rel: string, content: string): void { const abs = path.join(root, rel) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content) } /** A minimal CLIENT web root: package.json depending on @atlashub/smartstack. */ function makeClientWebRoot( webRel: string, opts: { installedVersion?: string; distMarkers?: string; declaredDep?: string } = {}, ): void { const declared = opts.declaredDep ?? opts.installedVersion ?? '3.64.0' write( `${webRel}/package.json`, JSON.stringify({ name: 'crm-web', dependencies: { '@atlashub/smartstack': declared, react: '^19.0.0' } }), ) if (opts.installedVersion) { write( `${webRel}/node_modules/@atlashub/smartstack/package.json`, JSON.stringify({ name: '@atlashub/smartstack', version: opts.installedVersion }), ) } if (opts.distMarkers !== undefined) { write(`${webRel}/node_modules/@atlashub/smartstack/dist/smartstack.js`, opts.distMarkers) } } /** Every REQUIRED_PACKAGE_EXPORTS marker: the offline channel AND the mobile shell. */ const ALL_MARKERS = 'export function setServiceWorkerUpdater(){}\n' + 'export function initOutbox(){}\n' + 'export function MobileShell(){}\n' + 'export function useMobileNavContext(){}\n' /** A package that ships the offline channel but PREDATES the mobile shell. */ const OFFLINE_ONLY_MARKERS = 'export function setServiceWorkerUpdater(){}\nexport function initOutbox(){}\n' describe('gate 1 — Zod', () => { it('rejects a spec without appCode', async () => { const v = await validate({ projectPath: root }) expect(v.valid).toBe(false) expect(v.errors.join('\n')).toContain('appCode') }) it('rejects a non-kebab appCode and a bad themeColor', async () => { const v = await validate({ projectPath: root, appCode: 'CrmApp', manifest: { themeColor: 'red' } }) expect(v.valid).toBe(false) expect(v.errors.length).toBeGreaterThanOrEqual(2) }) }) describe('gate 2 — web root', () => { it('fails when the resolved web root does not exist (default web/{appCode}-web)', async () => { const v = await validate({ projectPath: root, appCode: 'crm' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('Web root not found') expect(v.errors[0].replace(/\\/g, '/')).toContain('web/crm-web') }) it('refuses a backend-looking web root (assertWebProjectRoot)', async () => { write('web/crm-web/package.json', JSON.stringify({ name: 'crm-web' })) write('web/crm-web/src/Crm.Domain/x.csproj', '') const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toMatch(/backend|\.NET/i) }) }) describe('gate 3 — frontend mode', () => { it("refuses the SOURCE monorepo — the socle manages its own PWA", async () => { write('web/crm-web/package.json', JSON.stringify({ name: '@atlashub/smartstack', version: '3.64.0' })) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('the socle manages its own PWA') expect(v.errors[0]).toContain('never scaffold there') }) it('refuses an unknown mode and lists the detector evidence', async () => { write('web/crm-web/package.json', JSON.stringify({ name: 'weird-fork' })) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('Evidence:') expect(v.errors[0]).toContain('no conclusive marker') }) }) describe('gate 4 — package gate', () => { it('passes when the installed dist exposes EVERY required export (probe) at the floor version', async () => { makeClientWebRoot('web/crm-web', { installedVersion: MIN_SMARTSTACK_PWA_VERSION, distMarkers: ALL_MARKERS }) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.errors).toEqual([]) expect(v.valid).toBe(true) expect(v.warnings).toEqual([]) expect(v.packageVersion).toBe(MIN_SMARTSTACK_PWA_VERSION) expect(v.spec?.appCode).toBe('crm') expect(v.webRootAbs?.endsWith('crm-web')).toBe(true) }) it('probe success WINS over a below-floor version (file:/pre-release install)', async () => { makeClientWebRoot('web/crm-web', { installedVersion: '0.0.0-dev.1', distMarkers: ALL_MARKERS, declaredDep: 'file:../smartstack' }) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(true) expect(v.warnings.join('\n')).toContain('below the') }) it('fails hard when NEITHER the probe NOR the version floor passes — naming the missing exports', async () => { makeClientWebRoot('web/crm-web', { installedVersion: '3.63.0', distMarkers: 'export const nothing = 1\n' }) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('setServiceWorkerUpdater') expect(v.errors[0]).toContain('initOutbox') expect(v.errors[0]).toContain(MIN_SMARTSTACK_PWA_VERSION) expect(v.errors[0]).toContain('3.63.0') }) it('fails when the package is not installed at all', async () => { makeClientWebRoot('web/crm-web', {}) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('not installed') }) it('accepts a version at/above the floor even when the probe cannot read the dist (warning)', async () => { makeClientWebRoot('web/crm-web', { installedVersion: '3.65.0' }) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(true) expect(v.warnings.join('\n')).toContain('capability probe') }) it('finds markers spread across several dist files', async () => { makeClientWebRoot('web/crm-web', { installedVersion: '3.60.0', distMarkers: 'export function initOutbox(){}\nexport function MobileShell(){}\n', }) write( 'web/crm-web/node_modules/@atlashub/smartstack/dist/index.d.ts', 'export declare function setServiceWorkerUpdater(u: unknown): void;\n' + 'export declare function useMobileNavContext(): unknown;\n', ) const probe = probePackageCapabilities(path.join(root, 'web', 'crm-web')) expect(probe.missing).toEqual([]) const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(true) }) it('flags a package that ships the offline channel but PREDATES the mobile shell', async () => { makeClientWebRoot('web/crm-web', { installedVersion: '3.63.0', distMarkers: OFFLINE_ONLY_MARKERS }) const probe = probePackageCapabilities(path.join(root, 'web', 'crm-web')) expect(probe.found).toEqual(expect.arrayContaining(['setServiceWorkerUpdater', 'initOutbox'])) expect(probe.missing).toEqual(['MobileShell', 'useMobileNavContext']) // Below the floor AND probe-incomplete → hard fail naming the shell exports: // `mobile: { enabled: true }` would be inert against such a package. const v = await validate({ projectPath: root, appCode: 'crm', webRoot: 'web/crm-web' }) expect(v.valid).toBe(false) expect(v.errors[0]).toContain('MobileShell') expect(v.errors[0]).toContain('useMobileNavContext') }) }) describe('web root resolution via the detector', () => { it('resolves web/-web automatically when webRoot is omitted', async () => { makeClientWebRoot('web/crm-web', { installedVersion: MIN_SMARTSTACK_PWA_VERSION, distMarkers: ALL_MARKERS }) const v = await validate({ projectPath: root, appCode: 'crm' }) expect(v.valid).toBe(true) expect(v.webRootAbs?.endsWith('crm-web')).toBe(true) }) }) describe('compareVersions', () => { it('orders numeric triples and tolerates range prefixes + prereleases', () => { expect(compareVersions('3.64.0', '3.64.0')).toBe(0) expect(compareVersions('3.65.0', '3.64.0')).toBeGreaterThan(0) expect(compareVersions('3.63.9', '3.64.0')).toBeLessThan(0) expect(compareVersions('^3.64.1', '3.64.0')).toBeGreaterThan(0) expect(compareVersions('3.64.0-rc.1', '3.64.0')).toBe(0) expect(compareVersions('0.0.0-dev.1', '3.64.0')).toBeLessThan(0) }) })