|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * Gate — every `Page` this package ships must serve the CANONICAL |
| 5 | + * `{ dialect, source }` envelope at every `ExpressionInputSchema` position |
| 6 | + * (#11480, extending #11255's platform-objects gate to this package). |
| 7 | + * |
| 8 | + * Both pages here are raw typed object literals reaching the kernel through |
| 9 | + * this plugin's own manifest bundles (`CLOUD_CONNECTION_UI_BUNDLE`, |
| 10 | + * `MARKETPLACE_INSTALLED_UI_BUNDLE`) — the same wire path as |
| 11 | + * `platform-objects`' pages, in files no `*.page.ts` sweep ever looked at. |
| 12 | + * They author ZERO expression keys today, which is exactly why the gate is |
| 13 | + * worth having: the hazard is the NEXT predicate added to one of them, which |
| 14 | + * would ship bare with every authoring-time signal green. |
| 15 | + * |
| 16 | + * The detector lives in `@objectstack/lint` (`page-envelope-audit.ts` — its |
| 17 | + * header carries the hazard and the three-door design; its own test file |
| 18 | + * carries the negative controls). What this file owns is this package's |
| 19 | + * POPULATION: the export-shape scan over `src/`, the per-page door |
| 20 | + * preconditions, the verdict, and a downgrade control proving the detector |
| 21 | + * reaches these real exports. |
| 22 | + * |
| 23 | + * ## The two exempted component types |
| 24 | + * |
| 25 | + * `cloud-connection:panel` and `marketplace:installed-list` are |
| 26 | + * console-registered widgets with no `ComponentPropsMap` row, so door 3 has |
| 27 | + * no schema to read their `properties` with. The exemption is asserted |
| 28 | + * EXACTLY (a new unmapped type reds), and it is valid only while those |
| 29 | + * components author an EMPTY props bag — nothing authored is nothing to |
| 30 | + * serve bare. The moment either widget grows a real authored prop, the |
| 31 | + * emptiness assert reds and forces the decision: give the type a |
| 32 | + * `ComponentPropsMap` row, or widen the exemption knowingly. |
| 33 | + */ |
| 34 | + |
| 35 | +import { readFileSync, readdirSync } from 'node:fs'; |
| 36 | +import { dirname, join } from 'node:path'; |
| 37 | +import { fileURLToPath } from 'node:url'; |
| 38 | +import { describe, expect, it } from 'vitest'; |
| 39 | +import type { Page } from '@objectstack/spec/ui'; |
| 40 | +import { |
| 41 | + auditPageExpressionEnvelopes, |
| 42 | + renderBareExpressionFindings, |
| 43 | + walkPageComponents, |
| 44 | +} from '@objectstack/lint'; |
| 45 | +import { CloudConnectionSettingsPage } from './cloud-connection-ui.js'; |
| 46 | +import { MarketplaceInstalledPage } from './marketplace-ui.js'; |
| 47 | + |
| 48 | +type AnyRec = Record<string, unknown>; |
| 49 | + |
| 50 | +/** This file lives in `src/`, so the scan root IS the package's `src/`. */ |
| 51 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 52 | + |
| 53 | +// ─────────────────────────────────────────────────────────────────────────── |
| 54 | +// The population this gate covers |
| 55 | +// ─────────────────────────────────────────────────────────────────────────── |
| 56 | + |
| 57 | +/** |
| 58 | + * Every page this package ships, audited by export name — with the unmapped |
| 59 | + * component types each page is EXPECTED to report (the exemptions above). |
| 60 | + */ |
| 61 | +const AUDITED_PAGES: { exportName: string; page: Page; exemptUnmappedTypes: string[] }[] = [ |
| 62 | + { |
| 63 | + exportName: 'CloudConnectionSettingsPage', |
| 64 | + page: CloudConnectionSettingsPage, |
| 65 | + exemptUnmappedTypes: ['cloud-connection:panel'], |
| 66 | + }, |
| 67 | + { |
| 68 | + exportName: 'MarketplaceInstalledPage', |
| 69 | + page: MarketplaceInstalledPage, |
| 70 | + exemptUnmappedTypes: ['marketplace:installed-list'], |
| 71 | + }, |
| 72 | +]; |
| 73 | + |
| 74 | +function pageLabel(exportName: string, page: Page): string { |
| 75 | + const name = typeof (page as AnyRec).name === 'string' ? (page as AnyRec).name : '(unnamed)'; |
| 76 | + return `${exportName} (${String(name)})`; |
| 77 | +} |
| 78 | + |
| 79 | +function tsFilesUnder(dir: string, out: string[] = []): string[] { |
| 80 | + for (const entry of readdirSync(dir, { withFileTypes: true })) { |
| 81 | + const full = join(dir, entry.name); |
| 82 | + if (entry.isDirectory()) { |
| 83 | + if (entry.name === 'node_modules' || entry.name === 'dist') continue; |
| 84 | + tsFilesUnder(full, out); |
| 85 | + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.test.ts')) { |
| 86 | + out.push(full); |
| 87 | + } |
| 88 | + } |
| 89 | + return out; |
| 90 | +} |
| 91 | + |
| 92 | +/** Strip comments so a `: Page =` inside prose is not read as a declaration. */ |
| 93 | +function stripComments(source: string): string { |
| 94 | + return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[ \t]*\/\/.*$/gm, ''); |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Every `export const X: Page = …` declared anywhere in this package's `src/`. |
| 99 | + * |
| 100 | + * Discovery is by EXPORT SHAPE, never by filename — the sweep that first |
| 101 | + * recorded this defect class looked at `*.page.ts` and therefore missed this |
| 102 | + * package's pages entirely (they live in `*-ui.ts` files). Scanning source |
| 103 | + * text rather than a barrel is what makes "a page nobody covered" visible. |
| 104 | + */ |
| 105 | +function declaredPageExports(): { name: string; file: string }[] { |
| 106 | + const out: { name: string; file: string }[] = []; |
| 107 | + for (const file of tsFilesUnder(HERE)) { |
| 108 | + const source = stripComments(readFileSync(file, 'utf8')); |
| 109 | + for (const match of source.matchAll(/export\s+const\s+(\w+)\s*:\s*Page\s*=/g)) { |
| 110 | + out.push({ name: match[1]!, file: file.slice(HERE.length + 1) }); |
| 111 | + } |
| 112 | + } |
| 113 | + return out.sort((a, b) => a.name.localeCompare(b.name)); |
| 114 | +} |
| 115 | + |
| 116 | +const AUDITS = AUDITED_PAGES.map(({ exportName, page, exemptUnmappedTypes }) => ({ |
| 117 | + exportName, |
| 118 | + page, |
| 119 | + exemptUnmappedTypes, |
| 120 | + audit: auditPageExpressionEnvelopes(page, pageLabel(exportName, page)), |
| 121 | +})); |
| 122 | + |
| 123 | +// ─────────────────────────────────────────────────────────────────────────── |
| 124 | +// The gate |
| 125 | +// ─────────────────────────────────────────────────────────────────────────── |
| 126 | + |
| 127 | +describe('cloud-connection Page exports serve canonical expression envelopes', () => { |
| 128 | + it('covers every `Page` declared in this package — and audits nothing undeclared', () => { |
| 129 | + const declared = declaredPageExports(); |
| 130 | + const audited = new Set(AUDITED_PAGES.map(p => p.exportName)); |
| 131 | + const uncovered = declared.filter(d => !audited.has(d.name)); |
| 132 | + expect( |
| 133 | + uncovered.map(d => `${d.name} (${d.file})`).join('\n'), |
| 134 | + 'a raw-literal `Page` in this package is not audited by this gate. Add it to ' |
| 135 | + + 'AUDITED_PAGES above (or, if it is deliberately unshipped, say so here).', |
| 136 | + ).toBe(''); |
| 137 | + |
| 138 | + // Both directions: a page audited here but invisible to the export-shape |
| 139 | + // scan means its declaration lost the `: Page` annotation — the exact way |
| 140 | + // MarketplaceInstalledPage shipped un-discoverable before #11480. |
| 141 | + const declaredNames = new Set(declared.map(d => d.name)); |
| 142 | + const undeclared = AUDITED_PAGES.filter(p => !declaredNames.has(p.exportName)); |
| 143 | + expect( |
| 144 | + undeclared.map(p => p.exportName).join('\n'), |
| 145 | + 'this page is audited but not discovered by the `export const X: Page =` scan — ' |
| 146 | + + 'restore the `: Page` annotation on its declaration so the NEXT page authored ' |
| 147 | + + 'beside it is discoverable too.', |
| 148 | + ).toBe(''); |
| 149 | + |
| 150 | + // Population floor: the gate is worthless if it silently reads nothing. |
| 151 | + expect(AUDITED_PAGES.length).toBeGreaterThanOrEqual(2); |
| 152 | + expect(declared.length).toBeGreaterThanOrEqual(2); |
| 153 | + }); |
| 154 | + |
| 155 | + it.each(AUDITS)('$exportName parses through PageSchema (door 1 precondition)', ({ audit }) => { |
| 156 | + expect( |
| 157 | + audit.pageParseError ?? '', |
| 158 | + 'door 1 cannot run: this page does not parse, so every schema-typed expression ' |
| 159 | + + 'position on it is unread by this gate.', |
| 160 | + ).toBe(''); |
| 161 | + }); |
| 162 | + |
| 163 | + it.each(AUDITS)('$exportName: every component parses through PageComponentSchema (door 2 precondition)', ({ audit }) => { |
| 164 | + expect( |
| 165 | + audit.componentParseErrors.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'), |
| 166 | + 'door 2 cannot run for these components: they do not parse, so their expression ' |
| 167 | + + 'positions are unread by this gate.', |
| 168 | + ).toBe(''); |
| 169 | + expect(audit.componentCount).toBeGreaterThan(0); |
| 170 | + }); |
| 171 | + |
| 172 | + it.each(AUDITS)('$exportName: unmapped component types are EXACTLY the recorded exemptions (door 3 precondition)', ({ audit, exemptUnmappedTypes }) => { |
| 173 | + // See the module header for why these two types are exempt. Anything else |
| 174 | + // unmapped is a new door-3 blind spot: declare the props schema in |
| 175 | + // `ComponentPropsMap`, or record the exemption here with the reason. |
| 176 | + expect(audit.unmappedTypes.map(e => e.type).sort()).toEqual([...exemptUnmappedTypes].sort()); |
| 177 | + }); |
| 178 | + |
| 179 | + it.each(AUDITS)('$exportName: every exempted component authors an EMPTY props bag', ({ page, exemptUnmappedTypes }) => { |
| 180 | + // The exemption above is only sound while there is nothing authored for |
| 181 | + // door 3 to miss. A real key landing in one of these bags must force a |
| 182 | + // decision (props schema row, or a conscious wider exemption) — not ride |
| 183 | + // through a standing exemption silently. |
| 184 | + const offenders = walkPageComponents(page as AnyRec, '') |
| 185 | + .filter(w => typeof w.component.type === 'string' && exemptUnmappedTypes.includes(w.component.type)) |
| 186 | + .filter(w => { |
| 187 | + const props = w.component.properties; |
| 188 | + return !!props && typeof props === 'object' && Object.keys(props).length > 0; |
| 189 | + }) |
| 190 | + .map(w => `${w.path} [${String(w.component.type)}]`); |
| 191 | + expect(offenders.join('\n')).toBe(''); |
| 192 | + }); |
| 193 | + |
| 194 | + it.each(AUDITS)('$exportName: every authored `properties` bag parses against its props schema (door 3 precondition)', ({ audit }) => { |
| 195 | + expect( |
| 196 | + audit.unreadableProps.map(e => `${e.path} [${e.type}]: ${e.issues}`).join('\n'), |
| 197 | + 'door 3 cannot run for these components: their authored `properties` are refused by ' |
| 198 | + + 'the declared props schema, so a props-level expression key there is unread by ' |
| 199 | + + 'this gate.', |
| 200 | + ).toBe(''); |
| 201 | + }); |
| 202 | + |
| 203 | + it.each(AUDITS)('$exportName authors NO bare expression string', ({ audit }) => { |
| 204 | + expect(renderBareExpressionFindings(audit.findings)).toBe(''); |
| 205 | + }); |
| 206 | +}); |
| 207 | + |
| 208 | +// ─────────────────────────────────────────────────────────────────────────── |
| 209 | +// Downgrade control — the imported detector reaches this package's REAL pages |
| 210 | +// ─────────────────────────────────────────────────────────────────────────── |
| 211 | + |
| 212 | +const BARE = 'has(record.status) && record.status == "bound"'; |
| 213 | + |
| 214 | +describe('downgrade control — a shipped page, bare predicate injected', () => { |
| 215 | + it('flags CloudConnectionSettingsPage the moment a bare predicate lands on its panel', () => { |
| 216 | + // Deep-cloned — the export itself is untouched (the pristine re-audit |
| 217 | + // below proves it). The injected position is the panel component's |
| 218 | + // `visibleWhen`, i.e. the exact next-predicate the card names as the |
| 219 | + // hazard for this page. |
| 220 | + const source = JSON.parse(JSON.stringify(CloudConnectionSettingsPage)) as AnyRec; |
| 221 | + const regions = source.regions as AnyRec[]; |
| 222 | + const panel = (regions[1]!.components as AnyRec[])[0]!; |
| 223 | + expect(panel.type).toBe('cloud-connection:panel'); |
| 224 | + panel.visibleWhen = BARE; |
| 225 | + |
| 226 | + const audit = auditPageExpressionEnvelopes(source, pageLabel('CloudConnectionSettingsPage', source as unknown as Page)); |
| 227 | + expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']); |
| 228 | + const rendered = renderBareExpressionFindings(audit.findings); |
| 229 | + expect(rendered).toContain('cloud_connection_settings'); |
| 230 | + expect(rendered).toContain('regions[1].components[0].visibleWhen'); |
| 231 | + expect(rendered).toContain('authored BARE'); |
| 232 | + |
| 233 | + const pristine = auditPageExpressionEnvelopes( |
| 234 | + CloudConnectionSettingsPage, |
| 235 | + pageLabel('CloudConnectionSettingsPage', CloudConnectionSettingsPage), |
| 236 | + ); |
| 237 | + expect(renderBareExpressionFindings(pristine.findings)).toBe(''); |
| 238 | + }); |
| 239 | + |
| 240 | + it('flags MarketplaceInstalledPage the same way — the page the `: Page` scan used to miss', () => { |
| 241 | + const source = JSON.parse(JSON.stringify(MarketplaceInstalledPage)) as AnyRec; |
| 242 | + const regions = source.regions as AnyRec[]; |
| 243 | + const list = (regions[1]!.components as AnyRec[])[0]!; |
| 244 | + expect(list.type).toBe('marketplace:installed-list'); |
| 245 | + list.visibleWhen = BARE; |
| 246 | + |
| 247 | + const audit = auditPageExpressionEnvelopes(source, pageLabel('MarketplaceInstalledPage', source as unknown as Page)); |
| 248 | + expect(audit.findings.map(f => f.path)).toEqual(['regions[1].components[0].visibleWhen']); |
| 249 | + const rendered = renderBareExpressionFindings(audit.findings); |
| 250 | + expect(rendered).toContain('marketplace_installed'); |
| 251 | + |
| 252 | + const pristine = auditPageExpressionEnvelopes( |
| 253 | + MarketplaceInstalledPage, |
| 254 | + pageLabel('MarketplaceInstalledPage', MarketplaceInstalledPage), |
| 255 | + ); |
| 256 | + expect(renderBareExpressionFindings(pristine.findings)).toBe(''); |
| 257 | + }); |
| 258 | +}); |
0 commit comments