You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/test/isolation.test.ts

80 lines
3.0 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
/**
* Bundle isolation: the store apps (store-server / pos / backoffice) and the HQ
* apps (hq / hq-web) are separate deliverables that must never import each other.
* A static, dependency-free scan of their src/ trees enforces the wall — shared
* @sims/* packages are the only sanctioned bridge. See docs/15 (D15 one-repo/two-apps).
*/
const APPS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const STORE_APPS = ['store-server', 'pos', 'backoffice']
const HQ_APPS = ['hq', 'hq-web']
/** Every .ts/.tsx file under apps/<name>/src, recursively. */
function sourceFiles(app: string): string[] {
const root = path.join(APPS_DIR, app, 'src')
const out: string[] = []
const walk = (dir: string): void => {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.tsx?$/.test(entry.name)) out.push(full)
}
}
walk(root)
return out
}
/** Static import / re-export / dynamic-import / require specifiers in a source file. */
function importSpecifiers(file: string): string[] {
const src = fs.readFileSync(file, 'utf8')
const re = /\b(?:from|import|require)\b\s*\(?\s*['"]([^'"]+)['"]/g
const specs: string[] = []
let m: RegExpExecArray | null
while ((m = re.exec(src)) !== null) specs.push(m[1]!)
return specs
}
/** Does `spec`, imported from `file`, reach into one of the forbidden app trees? */
function crossesInto(file: string, spec: string, forbiddenDirs: string[], forbiddenNames: string[]): boolean {
if (spec.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), spec)
return forbiddenDirs.some((d) => resolved === d || resolved.startsWith(d + path.sep))
}
// Bare specifiers: @sims/* and third-party deps are fine; an explicit apps/<other> path is not.
return forbiddenNames.some((name) => spec.includes(`apps/${name}`) || spec.includes(`apps\\${name}`))
}
function violations(fromApps: string[], toApps: string[]): string[] {
const forbiddenDirs = toApps.map((a) => path.join(APPS_DIR, a))
const found: string[] = []
for (const app of fromApps) {
for (const file of sourceFiles(app)) {
for (const spec of importSpecifiers(file)) {
if (crossesInto(file, spec, forbiddenDirs, toApps)) {
found.push(`${path.relative(APPS_DIR, file)} imports '${spec}'`)
}
}
}
}
return found
}
describe('bundle isolation between store apps and HQ apps', () => {
it('scans a non-empty set of source files (guards against a broken path)', () => {
expect([...STORE_APPS, ...HQ_APPS].flatMap(sourceFiles).length).toBeGreaterThan(0)
})
it('store apps never import HQ apps', () => {
expect(violations(STORE_APPS, HQ_APPS)).toEqual([])
})
it('HQ apps never import store apps', () => {
expect(violations(HQ_APPS, STORE_APPS)).toEqual([])
})
})