The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.
- tests/admin-resources.test.ts: vitest-based verifier that walks
every registered resource and asserts:
- identity fields (key/label/plural/singular/groupKey)
- list.queryFn, action.handler, ops.* members are functions
- column kinds are in the registered set (text/pill/relative-date/
number/tag-list); same for columnsByFilter overrides
- field kinds are in the registered set (11 kinds)
- embed.component is in the registered set (pulse-sub-form)
- resource keys are unique, action keys are unique per resource
- at most one filter is isDefault
- groupKey resolves to a real group
- review-mode resources have at least one action
- ops.create requires a non-null form
87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
UserEditTab — all replaced by the resource configs).
Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.2 KiB
TypeScript
168 lines
6.2 KiB
TypeScript
/* ---------------------------------------------------------------------------
|
|
* Verifier for the Backstage admin resource registry.
|
|
*
|
|
* Walks every registered resource and asserts the invariants that keep the
|
|
* shared components renderable. Compile-time TypeScript already catches most
|
|
* shape issues via the strict Resource<T> generic — this suite covers what
|
|
* TS can't see at the value level (function-ness of handlers, kind strings
|
|
* actually being in the registered set, sentinel resource keys not colliding).
|
|
*
|
|
* Note on "every column.key is a valid field on the entity":
|
|
* That's a structural assertion best enforced at compile time. Resource<T>
|
|
* narrows the render/value callbacks to the entity's keys; this suite skips
|
|
* trying to re-check it at runtime.
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { groups } from '../src/admin/resources';
|
|
import type {
|
|
Field,
|
|
Resource,
|
|
ResourceGroup,
|
|
Column,
|
|
FormEmbed,
|
|
} from '../src/admin/resource-types';
|
|
|
|
const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
|
|
'text',
|
|
'textarea',
|
|
'markdown',
|
|
'select',
|
|
'select-async',
|
|
'multi-select-async',
|
|
'multi-text',
|
|
'date',
|
|
'datetime',
|
|
'number',
|
|
'readonly',
|
|
]);
|
|
|
|
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([
|
|
'text', 'pill', 'relative-date', 'number', 'tag-list',
|
|
]);
|
|
|
|
const KNOWN_EMBED_COMPONENTS: ReadonlySet<FormEmbed['component']> = new Set([
|
|
'pulse-sub-form',
|
|
]);
|
|
|
|
function allResources(): Resource[] {
|
|
return groups.flatMap((g: ResourceGroup) => g.resources as Resource[]);
|
|
}
|
|
|
|
describe('admin resource registry', () => {
|
|
it('has at least one group with resources registered', () => {
|
|
expect(groups.length).toBeGreaterThan(0);
|
|
expect(allResources().length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('every resource key is unique across the registry', () => {
|
|
const keys = allResources().map((r) => r.key);
|
|
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
expect(dups).toEqual([]);
|
|
});
|
|
|
|
it('every resource.groupKey points at a real group', () => {
|
|
const groupKeys = new Set(groups.map((g) => g.key));
|
|
for (const r of allResources()) {
|
|
expect(groupKeys.has(r.groupKey), `${r.key} → unknown groupKey ${r.groupKey}`).toBe(true);
|
|
}
|
|
});
|
|
|
|
describe.each(allResources())('resource: $key', (resource: Resource) => {
|
|
it('has required identity fields', () => {
|
|
expect(resource.key).toBeTruthy();
|
|
expect(resource.label).toBeTruthy();
|
|
expect(resource.pluralLabel).toBeTruthy();
|
|
expect(resource.singularLabel).toBeTruthy();
|
|
expect(resource.groupKey).toBeTruthy();
|
|
});
|
|
|
|
it('list.queryFn is a function', () => {
|
|
expect(typeof resource.list.queryFn).toBe('function');
|
|
});
|
|
|
|
it('every column has a registered kind (or none = text)', () => {
|
|
for (const col of resource.list.columns as Column<unknown>[]) {
|
|
const kind = col.kind ?? 'text';
|
|
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: column ${col.key} → unknown kind ${kind}`).toBe(true);
|
|
}
|
|
if (resource.list.columnsByFilter) {
|
|
for (const [filterKey, cols] of Object.entries(resource.list.columnsByFilter)) {
|
|
for (const col of cols as Column<unknown>[]) {
|
|
const kind = col.kind ?? 'text';
|
|
expect(KNOWN_COLUMN_KINDS.has(kind), `${resource.key}: columnsByFilter.${filterKey}.${col.key} → unknown kind ${kind}`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('exactly one or zero filters is isDefault', () => {
|
|
const filters = resource.list.filters ?? [];
|
|
const defaults = filters.filter((f) => f.isDefault);
|
|
expect(defaults.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('every filter.predicate is a function', () => {
|
|
for (const f of resource.list.filters ?? []) {
|
|
expect(typeof f.predicate, `${resource.key}: filter ${f.key} predicate`).toBe('function');
|
|
}
|
|
});
|
|
|
|
it('every form field has a registered kind', () => {
|
|
for (const field of resource.form?.fields ?? []) {
|
|
expect(
|
|
KNOWN_FIELD_KINDS.has(field.kind),
|
|
`${resource.key}: field ${field.key} → unknown kind ${field.kind}`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('every embed.component is in the registered set', () => {
|
|
for (const embed of resource.form?.embeds ?? []) {
|
|
expect(
|
|
KNOWN_EMBED_COMPONENTS.has(embed.component),
|
|
`${resource.key}: embed ${embed.key} → unknown component ${embed.component}`,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('every ops member is a function (when defined)', () => {
|
|
const ops = resource.ops;
|
|
if (ops.create) expect(typeof ops.create).toBe('function');
|
|
if (ops.update) expect(typeof ops.update).toBe('function');
|
|
if (ops.delete) expect(typeof ops.delete).toBe('function');
|
|
if (ops.getById) expect(typeof ops.getById).toBe('function');
|
|
});
|
|
|
|
it('every action.handler is a function', () => {
|
|
for (const action of resource.actions ?? []) {
|
|
expect(typeof action.handler, `${resource.key}: action ${action.key}`).toBe('function');
|
|
}
|
|
});
|
|
|
|
it('action keys are unique', () => {
|
|
const keys = (resource.actions ?? []).map((a) => a.key);
|
|
const dups = keys.filter((k, i) => keys.indexOf(k) !== i);
|
|
expect(dups).toEqual([]);
|
|
});
|
|
|
|
it('renders SOMETHING when an item is clicked (form OR summary, or no clicks)', () => {
|
|
// If form is null and there's no summary, the resource is non-clickable.
|
|
// If form is null but a summary is defined → review panel renders.
|
|
// If form is defined → edit panel renders.
|
|
// The only invalid shape is form=null + summary defined + no actions,
|
|
// which would render an empty review panel. Flag it.
|
|
if (resource.form === null && resource.summary !== undefined) {
|
|
const actions = resource.actions ?? [];
|
|
expect(actions.length, `${resource.key}: review-mode resource with no actions`).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('when ops.create is defined, the form is defined too', () => {
|
|
// Can't render the create panel without a form.
|
|
if (resource.ops.create) {
|
|
expect(resource.form, `${resource.key}: ops.create is defined but form is null`).not.toBeNull();
|
|
}
|
|
});
|
|
});
|
|
});
|