Corporate Tools is the first PCDetail tab that mounts no PluginAssetPanels: it is permission-gated and renders a fixed action list, so no panel can land in it. The vocabulary check asserted every tabs.push key appears in RENDERED_GROUPS, which cd70556 broke by adding that tab.
Adding 'corptools' to the vocabulary would have made it a legal panel group with no panel host - fetched, held, rendered nowhere - the silent-drop failure this file exists to catch, and it would also have failed the server-vocabulary check against ASSET_PANEL_GROUPS. So the invariant is narrowed to panel-backed tabs instead, with a mirror check that any tab mounting a PluginAssetPanels must have its group rendered.
122 lines
5.1 KiB
JavaScript
122 lines
5.1 KiB
JavaScript
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) {
|
|
return readFileSync(fileURLToPath(new URL(relative, import.meta.url)), '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"')
|
|
})
|
|
})
|