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