import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' // The group vocabulary is split across four files and there is no runtime that // checks they agree. When they disagree the failure is SILENT: a panel in a // group nothing renders is fetched, held, and shown on no tab, with nothing // logged. That has eaten cards twice - tracked services moving out of // 'actions', and the 'metrics' to 'monitors' rename. // // So this reads the sources and asserts the vocabularies match. It is a source // check rather than a behaviour check because RENDERED_GROUPS is a module-local // constant in the .vue file with no export to assert against. function read(relative) { // .href, not the URL object: the jsdom test environment supplies its own // URL global, and node's fileURLToPath rejects a foreign instance with // "must be ... an instance of URL. Received an instance of URL". return readFileSync(fileURLToPath(new URL(relative, import.meta.url).href), 'utf8') } function renderedGroups() { const source = read('./PluginAssetPanels.vue') const match = source.match(/const RENDERED_GROUPS = \[([^\]]*)\]/) if (!match) throw new Error('RENDERED_GROUPS not found in PluginAssetPanels.vue') return match[1].split(',') .map((entry) => entry.trim().replace(/^'|'$/g, '')) .filter(Boolean) } function pcDetailSource() { return read('../../../plugins/computers/frontend/views/PCDetail.vue') } // The tabs that actually HOST panels - those where PCDetail mounts a // PluginAssetPanels for the group. A tab is only bound by the vocabulary if a // panel can land in it. Corporate Tools is the first tab that hosts none: it is // permission-gated and renders a fixed action list, so RENDERED_GROUPS has // nothing to say about it and requiring it there would make 'corptools' a legal // panel group with no panel host - fetched, held, rendered nowhere, which is // the very failure this file exists to catch. function panelHostedGroups(source) { return [...source.matchAll(/group="([a-z]+)"/g)].map((match) => match[1]) } describe('panel group vocabulary', () => { it('renders a group for every panel-backed tab PCDetail offers', () => { const groups = renderedGroups() const source = pcDetailSource() const hosted = panelHostedGroups(source) const tabKeys = [...source.matchAll(/tabs\.push\(\{\s*key:\s*'([a-z]+)'/g)] .map((match) => match[1]) .filter((key) => hosted.includes(key)) expect(tabKeys.length).toBeGreaterThan(0) for (const key of tabKeys) { expect(groups).toContain(key) } }) it('never hosts panels in a group this build does not render', () => { // The mirror of that exemption, so opting a tab out of panels cannot become // a loophole: the moment a tab mounts a PluginAssetPanels, its group MUST // be rendered, or the panels are fetched and dropped with nothing logged. const groups = renderedGroups() const hosted = panelHostedGroups(pcDetailSource()) expect(hosted.length).toBeGreaterThan(0) for (const group of hosted) { expect(groups).toContain(group) } }) it('gives every rendered group somewhere to actually show', () => { // A group in RENDERED_GROUPS with no v-show block is the mirror failure: // the panel is kept rather than dropped, but still appears nowhere. const source = pcDetailSource() for (const group of renderedGroups()) { expect(source).toContain(`activegroup === '${group}'`) } }) it('keeps the service/process split wired end to end', () => { // Pins the specific split rather than only the general rule, so removing // the tab is a deliberate edit to this test and not a silent regression. const source = pcDetailSource() expect(renderedGroups()).toContain('serviceprocess') expect(source).toContain("key: 'serviceprocess'") expect(source).toContain("group=\"serviceprocess\"") }) it('still renders the system monitors group after the rename', () => { // The rename is label-only; the group key must not drift with it. expect(renderedGroups()).toContain('monitors') expect(pcDetailSource()).toContain("label: 'System Monitors'") }) it('agrees with the server vocabulary that resolves groups first', () => { // The fourth file this comment block claims to cover, and the one that was // NOT actually being checked. asset_panels() rewrites any group missing // from ASSET_PANEL_GROUPS to 'details' before the client sees it, so the // frontend can be perfectly consistent with itself and still show nothing: // that is exactly how the Service/Process tab went missing after Phase 3. const source = read('../../../shopdb/core/api/pluginui.py') const match = source.match(/ASSET_PANEL_GROUPS = \(([\s\S]*?)\)/) if (!match) throw new Error('ASSET_PANEL_GROUPS not found in pluginui.py') const serverGroups = match[1].split(',') .map((entry) => entry.trim().replace(/^'|'$/g, '')) .filter(Boolean) for (const group of renderedGroups()) { expect(serverGroups).toContain(group) } }) it('keeps operator reports in a tab of its own', () => { const source = pcDetailSource() expect(renderedGroups()).toContain('reports') expect(source).toContain("key: 'reports'") expect(source).toContain('group="reports"') }) })