feat(housekeeping): cut over administration to ase
This commit is contained in:
1 parent
c9e35cf602
commit
2b8f73a91d
457 files changed
+589
-55936
No files matched your search
@@ -13,8 +13,8 @@ describe("Housekeeping runtime parity", () => {
|
||||
HOUSEKEEPING_ROUTE_HANDLERS,
|
||||
);
|
||||
|
||||
it("closes all 137 discovered migration rows against the runtime", () => {
|
||||
expect(discoverLegacyPages()).toHaveLength(137);
|
||||
it("closes all 137 recorded migration rows after removing legacy routes", () => {
|
||||
expect(discoverLegacyPages()).toEqual([]);
|
||||
expect(report).toEqual({
|
||||
discovered: 137,
|
||||
mapped: 137,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CANONICAL_ENTRYPOINTS = [
|
||||
"src/app/ase/layout.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
"src/features/housekeeping/route-handlers.ts",
|
||||
] as const;
|
||||
|
||||
const REMOVED_UI_ROOTS = [
|
||||
"src/app/admin",
|
||||
"src/app/admin-next",
|
||||
"src/app/ase-next",
|
||||
"src/app/mod",
|
||||
] as const;
|
||||
|
||||
const CANONICAL_GLOBAL_SOURCES = [
|
||||
"src/features/housekeeping/foundation/routing/href.ts",
|
||||
"src/lib/admin/guard.ts",
|
||||
"src/lib/proxy-access.ts",
|
||||
"src/components/navigation.tsx",
|
||||
"src/components/top-header.tsx",
|
||||
] as const;
|
||||
|
||||
const REMOVED_UI_PATH = /\/(?:admin(?:-next)?|ase-next|mod)(?:[/?"'`]|$)/;
|
||||
|
||||
describe("atomic Housekeeping route cutover", () => {
|
||||
it("publishes the canonical /ase entrypoints and dispatcher", () => {
|
||||
for (const path of CANONICAL_ENTRYPOINTS) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes every legacy UI tree while preserving internal admin APIs", () => {
|
||||
for (const path of REMOVED_UI_ROOTS) {
|
||||
expect(existsSync(path), path).toBe(false);
|
||||
}
|
||||
|
||||
expect(existsSync("src/app/api/admin/csrf/route.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses /ase for global links, proxy access, and authorization fallbacks", () => {
|
||||
for (const path of CANONICAL_GLOBAL_SOURCES) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).toContain("/ase");
|
||||
expect(source, path).not.toMatch(REMOVED_UI_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the preview gate and environment flag", () => {
|
||||
expect(
|
||||
existsSync("src/features/housekeeping/foundation/preview-gate.ts"),
|
||||
).toBe(false);
|
||||
for (const path of ["src/env.ts", ".env.example"] as const) {
|
||||
expect(readFileSync(path, "utf8"), path).not.toContain(
|
||||
"HOUSEKEEPING_NEXT_PREVIEW_ENABLED",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not add redirects for removed UI paths", () => {
|
||||
for (const path of ["next.config.ts", "src/proxy.ts"] as const) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
expect(source, path).not.toMatch(
|
||||
/(?:source|destination|redirect)\s*[:(][^\n]*(?:\/admin|\/admin-next|\/ase-next|\/mod)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,11 +32,6 @@ const expected = [
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.editorial.navigation.update",
|
||||
"navigation.update",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
|
||||
@@ -39,11 +39,6 @@ const CONTENT_COMMAND_DEFINITIONS = [
|
||||
["content.media.photo.delete", "photo.delete", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.upload", "media.upload", PERMS.PAGES_EDIT],
|
||||
["content.media.asset.delete", "media.delete", PERMS.PAGES_EDIT],
|
||||
[
|
||||
"content.editorial.navigation.update",
|
||||
"navigation.update",
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
["content.editorial.tag.change", "tag.change", PERMS.PAGES_EDIT],
|
||||
["content.engagement.prefix.change", "prefix.change", PERMS.PREFIXES_EDIT],
|
||||
[
|
||||
|
||||
@@ -15,7 +15,6 @@ export function ContentEditorialPage({
|
||||
}: ContentPageProps) {
|
||||
const canNews = context.has(PERMS.NEWS_EDIT);
|
||||
const canPages = context.has(PERMS.PAGES_EDIT);
|
||||
const canSettings = context.has(PERMS.SETTINGS_EDIT);
|
||||
return (
|
||||
<ContentPageFrame
|
||||
title="Editorial content"
|
||||
@@ -49,22 +48,6 @@ export function ContentEditorialPage({
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canSettings && routeId === "content.editorial.articles" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.navigation.update"
|
||||
buttonLabel="Save navigation"
|
||||
input={{}}
|
||||
fields={[
|
||||
{
|
||||
name: "items",
|
||||
label: "Navigation JSON",
|
||||
type: "json",
|
||||
required: true,
|
||||
maxLength: 20_000,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{canPages && routeId === "content.editorial.tags" ? (
|
||||
<ContentCommandForm
|
||||
commandId="content.editorial.tag.change"
|
||||
|
||||
@@ -5,11 +5,6 @@ import path from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as JSONC from "jsonc-parser";
|
||||
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
|
||||
import {
|
||||
ADMIN_NAV_CONFIG_KEY,
|
||||
type AdminNavConfig,
|
||||
serializeAdminNavConfig,
|
||||
} from "@/lib/admin-nav-config";
|
||||
import { getClientTranslationFile } from "@/lib/client-translation-files";
|
||||
import { CameraWeb, db, EmulatorSettings, WebsiteSetting } from "@/lib/db";
|
||||
import { patchJson5 } from "@/lib/json5-patch";
|
||||
@@ -300,27 +295,6 @@ async function photoDelete(
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function navigationUpdate(
|
||||
input: unknown,
|
||||
): Promise<ContentMutationSnapshot> {
|
||||
const data = record(input);
|
||||
const raw = data.items ?? data.config ?? data;
|
||||
const config = jsonRecord(raw) as AdminNavConfig;
|
||||
const value = serializeAdminNavConfig(config);
|
||||
const beforeValue = await siteSettings.get(ADMIN_NAV_CONFIG_KEY, null);
|
||||
await siteSettings.update(ADMIN_NAV_CONFIG_KEY, value);
|
||||
return {
|
||||
before: { configured: Boolean(beforeValue) },
|
||||
after: {
|
||||
configured: true,
|
||||
groups: config.groupOrder?.length ?? 0,
|
||||
hiddenGroups: config.hiddenGroups?.length ?? 0,
|
||||
hiddenItems: config.hiddenItems?.length ?? 0,
|
||||
},
|
||||
output: { saved: true },
|
||||
};
|
||||
}
|
||||
|
||||
async function themeUpdate(
|
||||
input: unknown,
|
||||
context: ContentMutationContext,
|
||||
@@ -802,7 +776,6 @@ const EXTERNAL_HANDLERS: Partial<
|
||||
"media.upload": (input) => mediaUpload(input),
|
||||
"media.delete": (input) => mediaDelete(input),
|
||||
"photo.delete": photoDelete,
|
||||
"navigation.update": (input) => navigationUpdate(input),
|
||||
"theme.update": themeUpdate,
|
||||
"theme.apply-preset": themeApplyPreset,
|
||||
"theme.custom-change": themeCustomChange,
|
||||
|
||||
@@ -34,7 +34,6 @@ export const CONTENT_EXTERNAL_OPERATIONS = [
|
||||
|
||||
export const CONTENT_MIXED_OPERATIONS = [
|
||||
"photo.delete",
|
||||
"navigation.update",
|
||||
"theme.update",
|
||||
"theme.apply-preset",
|
||||
"theme.custom-change",
|
||||
|
||||
@@ -23,7 +23,6 @@ export const CONTENT_MUTATION_OPERATIONS = [
|
||||
"photo.delete",
|
||||
"media.upload",
|
||||
"media.delete",
|
||||
"navigation.update",
|
||||
"tag.change",
|
||||
"prefix.change",
|
||||
"prefix-blacklist.change",
|
||||
@@ -105,7 +104,6 @@ const OPERATION_PERMISSION = Object.freeze({
|
||||
"photo.delete": PERMS.PAGES_EDIT,
|
||||
"media.upload": PERMS.PAGES_EDIT,
|
||||
"media.delete": PERMS.PAGES_EDIT,
|
||||
"navigation.update": PERMS.SETTINGS_EDIT,
|
||||
"tag.change": PERMS.PAGES_EDIT,
|
||||
"prefix.change": PERMS.PREFIXES_EDIT,
|
||||
"prefix-blacklist.change": PERMS.PREFIXES_EDIT,
|
||||
|
||||
@@ -2,8 +2,8 @@ import "server-only";
|
||||
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import { CatalogDetailTabs } from "@/app/admin/catalog/[id]/catalog-detail-tabs";
|
||||
import { BcPageDetail } from "@/app/admin/catalog/builder-club/bc-manager";
|
||||
import { BcPageDetail } from "@/components/admin/catalog/builder-club/bc-manager";
|
||||
import { CatalogDetailTabs } from "@/components/admin/catalog/detail/catalog-detail-tabs";
|
||||
import { CatalogItemsBc, CatalogPages, CatalogPagesBc, db } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingRouteDefinition } from "../../../foundation/contracts";
|
||||
import type { HousekeepingPreferences } from "../../../foundation/preferences/schema";
|
||||
import type { HousekeepingWidgetOption } from "../../../foundation/preferences/widget-options";
|
||||
import type { HousekeepingWidgetLoadResult } from "../../../foundation/registry";
|
||||
@@ -47,7 +44,6 @@ export function OperationsPersonalization({
|
||||
routes,
|
||||
widgets,
|
||||
widgetResult,
|
||||
surface,
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
@@ -57,7 +53,6 @@ export function OperationsPersonalization({
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly widgets: readonly HousekeepingWidgetOption[];
|
||||
readonly widgetResult: HousekeepingWidgetLoadResult;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly labels: OperationsPersonalizationLabels;
|
||||
readonly routeTitles: Readonly<Record<string, string>>;
|
||||
readonly widgetTitles: Readonly<Record<string, string>>;
|
||||
@@ -69,7 +64,6 @@ export function OperationsPersonalization({
|
||||
<Favorites
|
||||
preferences={preferences}
|
||||
routes={routes}
|
||||
surface={surface}
|
||||
savePreferences={savePreferences}
|
||||
labels={labels.favorites}
|
||||
titles={routeTitles}
|
||||
|
||||
@@ -123,7 +123,6 @@ describe("OperationsWorkspacePage", () => {
|
||||
result={result}
|
||||
routes={routes}
|
||||
widgetDefinitions={widgets}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={routeTitles}
|
||||
widgetTitles={widgetTitles}
|
||||
@@ -138,7 +137,7 @@ describe("OperationsWorkspacePage", () => {
|
||||
expect(html).toContain("Some workspace sections are unavailable");
|
||||
expect(html).not.toContain("system.alerts");
|
||||
expect(html).not.toContain("system.health");
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).toContain('href="/ase/people/users"');
|
||||
});
|
||||
|
||||
it("renders an explicit state when no operational domain is accessible", () => {
|
||||
@@ -147,7 +146,6 @@ describe("OperationsWorkspacePage", () => {
|
||||
result={result}
|
||||
routes={[]}
|
||||
widgetDefinitions={[]}
|
||||
surface="preview"
|
||||
labels={labels}
|
||||
routeTitles={{}}
|
||||
widgetTitles={{}}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
} from "../../../foundation/contracts";
|
||||
import type { HousekeepingRouteDefinition } from "../../../foundation/contracts";
|
||||
import { HousekeepingPageShell } from "../../../foundation/page/housekeeping-page-shell";
|
||||
import type { HousekeepingWidgetOption } from "../../../foundation/preferences/widget-options";
|
||||
import { OperationalInbox } from "../../../foundation/shell/operational-inbox";
|
||||
@@ -41,7 +38,6 @@ export function OperationsWorkspacePage({
|
||||
result,
|
||||
routes,
|
||||
widgetDefinitions,
|
||||
surface,
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
@@ -51,7 +47,6 @@ export function OperationsWorkspacePage({
|
||||
readonly result: OperationsWorkspaceLoadResult;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly widgetDefinitions: readonly HousekeepingWidgetOption[];
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly labels: OperationsWorkspaceLabels;
|
||||
readonly routeTitles: Readonly<Record<string, string>>;
|
||||
readonly widgetTitles: Readonly<Record<string, string>>;
|
||||
@@ -93,13 +88,11 @@ export function OperationsWorkspacePage({
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<OperationalInbox
|
||||
response={result.model.inbox}
|
||||
surface={surface}
|
||||
labels={labels.inbox}
|
||||
titles={inboxTitles}
|
||||
/>
|
||||
<RecentWork
|
||||
items={result.model.recent}
|
||||
surface={surface}
|
||||
labels={labels.recent}
|
||||
titles={routeTitles}
|
||||
/>
|
||||
@@ -110,7 +103,6 @@ export function OperationsWorkspacePage({
|
||||
routes={routes}
|
||||
widgets={widgetDefinitions}
|
||||
widgetResult={result.model.widgets}
|
||||
surface={surface}
|
||||
labels={labels}
|
||||
routeTitles={routeTitles}
|
||||
widgetTitles={widgetTitles}
|
||||
|
||||
@@ -140,7 +140,6 @@ export async function renderOperationsWorkspacePage(
|
||||
result: workspaceResult,
|
||||
routes,
|
||||
widgetDefinitions: toHousekeepingWidgetOptions(widgetDefinitions),
|
||||
surface: input.surface ?? "preview",
|
||||
labels,
|
||||
routeTitles,
|
||||
widgetTitles,
|
||||
|
||||
@@ -516,7 +516,7 @@ describe("People actionable form contract", () => {
|
||||
|
||||
it("provides a real Next loading boundary", () => {
|
||||
const source = readFileSync(
|
||||
"src/app/ase-next/[domain]/[[...segments]]/loading.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/loading.tsx",
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain('data-housekeeping-state="loading"');
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
|
||||
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { ResultSetHeader } from "mysql2";
|
||||
import { MANAGED_SETTING_KEYS } from "@/app/admin/settings/cms-settings-config";
|
||||
import { MANAGED_SETTING_KEYS } from "@/lib/admin/cms-settings-config";
|
||||
import {
|
||||
AclModelPermission,
|
||||
AclModelRole,
|
||||
|
||||
@@ -64,7 +64,6 @@ describe("housekeeping accessibility contract", () => {
|
||||
]}
|
||||
labels={shellLabels}
|
||||
search={vi.fn()}
|
||||
surface="canonical"
|
||||
>
|
||||
<HousekeepingPageShell title="Operations" description="Daily work">
|
||||
<h2>Inbox</h2>
|
||||
@@ -128,7 +127,6 @@ describe("housekeeping accessibility contract", () => {
|
||||
capability,
|
||||
},
|
||||
]}
|
||||
surface="canonical"
|
||||
savePreferences={savePreferences}
|
||||
titles={{
|
||||
"pages.housekeeping.routes.people.users.list": "Users",
|
||||
@@ -198,7 +196,6 @@ describe("housekeeping accessibility contract", () => {
|
||||
partial: "Some data is unavailable",
|
||||
open: "Open",
|
||||
}}
|
||||
surface="canonical"
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { HousekeepingInboxSource } from "./inbox";
|
||||
import type { HousekeepingSearchProvider } from "./search";
|
||||
import type { HousekeepingWidgetDefinition } from "./widget";
|
||||
|
||||
export type HousekeepingSurface = "preview" | "canonical";
|
||||
export type CanonicalHousekeepingHref = "/ase" | `/ase/${string}`;
|
||||
export interface HousekeepingRouteDefinition {
|
||||
id: string;
|
||||
|
||||
@@ -11,7 +11,6 @@ export type {
|
||||
CanonicalHousekeepingHref,
|
||||
HousekeepingDomainManifest,
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
} from "./domain";
|
||||
export type {
|
||||
HousekeepingInboxSource,
|
||||
|
||||
@@ -11,10 +11,12 @@ import { OPERATIONS_ROUTES } from "../domains/operations/routes";
|
||||
import { PEOPLE_ROUTE_IDS } from "../domains/people/routes";
|
||||
import { SYSTEM_ROUTE_IDS } from "../domains/system/routes";
|
||||
import { HOUSEKEEPING_MANIFESTS } from "../manifests";
|
||||
import { discoverLegacyPages } from "../migration/discover-legacy-pages";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "../migration/discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "../migration/matrix";
|
||||
import { validateMigrationEntries } from "../migration/validate-matrix";
|
||||
import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
import { createHousekeepingRegistry } from "./registry";
|
||||
import { CommandTrigger } from "./shell/command-trigger";
|
||||
|
||||
@@ -484,8 +486,8 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/economy/pages/specialized-catalog-editor.tsx",
|
||||
new Set([
|
||||
"src/app/admin/catalog/[id]/catalog-detail-tabs",
|
||||
"src/app/admin/catalog/builder-club/bc-manager",
|
||||
"src/components/admin/catalog/detail/catalog-detail-tabs",
|
||||
"src/components/admin/catalog/builder-club/bc-manager",
|
||||
"src/features/housekeeping/domains/economy/routes",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
@@ -744,7 +746,7 @@ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
|
||||
[
|
||||
"src/features/housekeeping/domains/system/services/mutations.ts",
|
||||
new Set([
|
||||
"src/app/admin/settings/cms-settings-config",
|
||||
"src/lib/admin/cms-settings-config",
|
||||
"src/lib/db",
|
||||
"drizzle-orm",
|
||||
"mysql2",
|
||||
@@ -1542,14 +1544,11 @@ describe("housekeeping runtime import boundary", () => {
|
||||
});
|
||||
|
||||
describe("housekeeping foundation completion contracts", () => {
|
||||
it("leaves the current and preview route entrypoints present", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/layout.tsx",
|
||||
"src/app/mod/layout.tsx",
|
||||
"src/app/ase-next/layout.tsx",
|
||||
]) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
}
|
||||
it("keeps only the canonical administration route entrypoint", () => {
|
||||
expect(existsSync("src/app/ase/layout.tsx")).toBe(true);
|
||||
expect(existsSync("src/app/admin")).toBe(false);
|
||||
expect(existsSync("src/app/mod")).toBe(false);
|
||||
expect(existsSync("src/app/ase-next")).toBe(false);
|
||||
});
|
||||
|
||||
it("creates the real six-domain registry with every domain route enabled", () => {
|
||||
@@ -1595,12 +1594,6 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
).toEqual(SYSTEM_ROUTE_IDS);
|
||||
});
|
||||
|
||||
it("keeps production preview disabled even when the flag is true", () => {
|
||||
expect(
|
||||
isHousekeepingPreviewEnabled({ nodeEnv: "production", flag: true }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects executable React props before markup serialization", () => {
|
||||
const mutatedTrigger = createElement(
|
||||
"button",
|
||||
@@ -1626,7 +1619,6 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
close: "HK::close",
|
||||
},
|
||||
search: vi.fn(),
|
||||
surface: "preview",
|
||||
});
|
||||
const html = renderToStaticMarkup(trigger);
|
||||
const buttons = html.match(/<button\b[^>]*>/g) ?? [];
|
||||
@@ -1638,15 +1630,17 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(html).toContain(`>${sentinel}</span>`);
|
||||
});
|
||||
|
||||
it("validates the complete 137-row migration matrix without issues", () => {
|
||||
it("validates the recorded 137-row migration matrix after cutover", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
|
||||
const issues = validateMigrationEntries(
|
||||
discovered,
|
||||
recorded,
|
||||
HOUSEKEEPING_MIGRATION_MATRIX,
|
||||
);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
|
||||
expect(discovered).toHaveLength(137);
|
||||
expect(recorded).toHaveLength(137);
|
||||
expect(discovered).toEqual([]);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,6 @@ describe("housekeeping navigation", () => {
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.MOD_CFH_VIEW]),
|
||||
"canonical",
|
||||
(key) => key,
|
||||
);
|
||||
|
||||
@@ -108,7 +107,6 @@ describe("housekeeping navigation", () => {
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.USERS_VIEW]),
|
||||
"canonical",
|
||||
translate,
|
||||
);
|
||||
|
||||
@@ -140,7 +138,6 @@ describe("housekeeping navigation", () => {
|
||||
buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.MOD_CFH_VIEW]),
|
||||
"preview",
|
||||
translate,
|
||||
).map((domain) => domain.id),
|
||||
).toContain("people");
|
||||
@@ -148,7 +145,6 @@ describe("housekeeping navigation", () => {
|
||||
buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.MOD_CFH_VIEW]),
|
||||
"preview",
|
||||
translate,
|
||||
).map((domain) => domain.id),
|
||||
).not.toContain("economy");
|
||||
@@ -164,7 +160,6 @@ describe("housekeeping navigation", () => {
|
||||
const visibleDomainIds = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([slug]),
|
||||
"preview",
|
||||
translate,
|
||||
).map((domain) => domain.id);
|
||||
|
||||
@@ -173,7 +168,7 @@ describe("housekeeping navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps canonical navigation hrefs onto the requested surface", () => {
|
||||
it("keeps navigation hrefs on the canonical surface", () => {
|
||||
const registry = createHousekeepingRegistry([
|
||||
{
|
||||
id: "people",
|
||||
@@ -198,12 +193,11 @@ it("maps canonical navigation hrefs onto the requested surface", () => {
|
||||
const navigation = buildHousekeepingNavigation(
|
||||
registry,
|
||||
context([PERMS.USERS_VIEW]),
|
||||
"preview",
|
||||
(key) => key,
|
||||
);
|
||||
|
||||
expect(navigation[0]).toMatchObject({
|
||||
href: "/ase-next/people",
|
||||
items: [{ href: "/ase-next/people/users" }],
|
||||
href: "/ase/people",
|
||||
items: [{ href: "/ase/people/users" }],
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { satisfiesCapability } from "./capability-context";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingDomainManifest,
|
||||
HousekeepingSurface,
|
||||
} from "./contracts";
|
||||
import type { HousekeepingRegistry } from "./registry";
|
||||
import { toHousekeepingHref } from "./routing/href";
|
||||
@@ -20,14 +19,13 @@ export interface HousekeepingNavigationDomain {
|
||||
export function buildHousekeepingNavigation(
|
||||
registry: HousekeepingRegistry,
|
||||
context: HousekeepingCapabilityContext,
|
||||
surface: HousekeepingSurface,
|
||||
translate: (key: string) => string,
|
||||
): readonly HousekeepingNavigationDomain[] {
|
||||
return registry.domains
|
||||
.filter((domain) => satisfiesCapability(context, domain.capability))
|
||||
.map((domain) => ({
|
||||
id: domain.id,
|
||||
href: toHousekeepingHref(domain.canonicalHref, surface),
|
||||
href: toHousekeepingHref(domain.canonicalHref),
|
||||
iconId: domain.iconId,
|
||||
label: translate(domain.labelKey),
|
||||
description: translate(domain.descriptionKey),
|
||||
@@ -35,7 +33,7 @@ export function buildHousekeepingNavigation(
|
||||
.filter((route) => satisfiesCapability(context, route.capability))
|
||||
.map((route) => ({
|
||||
id: route.id,
|
||||
href: toHousekeepingHref(route.href, surface),
|
||||
href: toHousekeepingHref(route.href),
|
||||
label: translate(route.labelKey),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -106,12 +106,12 @@ describe("HousekeepingPageState", () => {
|
||||
state="error"
|
||||
title="Could not load"
|
||||
description="Try again later"
|
||||
retryAction={<a href="/ase-next/operations">Retry preview</a>}
|
||||
retryAction={<a href="/ase/operations">Retry preview</a>}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase-next/operations"');
|
||||
expect(html).toContain('href="/ase/operations"');
|
||||
expect(html).toContain(">Retry preview<");
|
||||
});
|
||||
});
|
||||
@@ -123,7 +123,7 @@ describe("HousekeepingPageShell", () => {
|
||||
title="People"
|
||||
description="Review operator-facing people data"
|
||||
context={<span>Preview context</span>}
|
||||
primaryAction={<a href="/ase-next/people/new">Create preview</a>}
|
||||
primaryAction={<a href="/ase/people/new">Create preview</a>}
|
||||
>
|
||||
<p>Page body</p>
|
||||
</HousekeepingPageShell>,
|
||||
@@ -133,7 +133,7 @@ describe("HousekeepingPageShell", () => {
|
||||
expect(html).toContain(">People<");
|
||||
expect(html).toContain(">Review operator-facing people data<");
|
||||
expect(html).toContain(">Preview context<");
|
||||
expect(html).toContain('href="/ase-next/people/new"');
|
||||
expect(html).toContain('href="/ase/people/new"');
|
||||
expect(html).toContain(">Create preview<");
|
||||
expect(html).toContain(">Page body</p>");
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
|
||||
describe("isHousekeepingPreviewEnabled", () => {
|
||||
it.each([
|
||||
["development", true, true],
|
||||
["test", true, true],
|
||||
["development", false, false],
|
||||
["production", true, false],
|
||||
["production", false, false],
|
||||
] as const)("NODE_ENV=%s flag=%s => %s", (nodeEnv, flag, expected) => {
|
||||
expect(isHousekeepingPreviewEnabled({ nodeEnv, flag })).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOUSEKEEPING_NEXT_PREVIEW_ENABLED", () => {
|
||||
const originalSkipValidation = process.env.SKIP_ENV_VALIDATION;
|
||||
const originalPreviewFlag = process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSkipValidation === undefined) {
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
} else {
|
||||
process.env.SKIP_ENV_VALIDATION = originalSkipValidation;
|
||||
}
|
||||
|
||||
if (originalPreviewFlag === undefined) {
|
||||
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
} else {
|
||||
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = originalPreviewFlag;
|
||||
}
|
||||
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["true", true],
|
||||
["1", true],
|
||||
["false", false],
|
||||
["yes", false],
|
||||
[undefined, false],
|
||||
] as const)("normalizes %s to %s", async (value, expected) => {
|
||||
if (value === undefined) {
|
||||
delete process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED;
|
||||
} else {
|
||||
process.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = value;
|
||||
}
|
||||
|
||||
const { env } = await import("@/env");
|
||||
|
||||
expect(env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export function isHousekeepingPreviewEnabled(input: {
|
||||
nodeEnv: "development" | "test" | "production";
|
||||
flag: boolean;
|
||||
}): boolean {
|
||||
return input.nodeEnv !== "production" && input.flag;
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type { HousekeepingCapabilityContext } from "./contracts";
|
||||
|
||||
const routeMocks = vi.hoisted(() => {
|
||||
const messages: Record<string, string> = {
|
||||
"preview.badge": "HK::preview-badge",
|
||||
"preview.commandDisabled": "HK::command-disabled",
|
||||
"preview.backToSite": "HK::back-to-site",
|
||||
"navigation.skipToContent": "HK::skip-to-content",
|
||||
@@ -161,10 +160,6 @@ const routeMocks = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
return {
|
||||
env: {
|
||||
NODE_ENV: "test" as "development" | "test" | "production",
|
||||
HOUSEKEEPING_NEXT_PREVIEW_ENABLED: true,
|
||||
},
|
||||
getHousekeepingCapabilityContext: vi.fn(),
|
||||
getTranslations: vi.fn(async (namespace: string) => {
|
||||
if (namespace !== "pages.housekeeping") {
|
||||
@@ -189,7 +184,6 @@ const routeMocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/env", () => ({ env: routeMocks.env }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: routeMocks.notFound,
|
||||
redirect: routeMocks.redirect,
|
||||
@@ -201,19 +195,19 @@ vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
|
||||
getHousekeepingCapabilityContext: routeMocks.getHousekeepingCapabilityContext,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
throw new Error("preview routes must not import the database");
|
||||
throw new Error("route entrypoints must not import the database");
|
||||
});
|
||||
vi.mock("@/lib/auth", () => {
|
||||
throw new Error("preview routes must not call auth directly");
|
||||
throw new Error("route entrypoints must not call auth directly");
|
||||
});
|
||||
vi.mock("@/lib/permissions", () => {
|
||||
throw new Error("preview routes must not reload permissions directly");
|
||||
throw new Error("route entrypoints must not reload permissions directly");
|
||||
});
|
||||
vi.mock("@/actions", () => {
|
||||
throw new Error("preview routes must not import actions");
|
||||
throw new Error("route entrypoints must not import actions");
|
||||
});
|
||||
vi.mock("@/app/actions", () => {
|
||||
throw new Error("preview routes must not import actions");
|
||||
throw new Error("route entrypoints must not import actions");
|
||||
});
|
||||
vi.mock("@/features/housekeeping/manifests", async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -299,16 +293,16 @@ vi.mock("@/features/housekeeping/route-handlers", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
import AdminNextDomainPage from "@/app/ase-next/[domain]/[[...segments]]/page";
|
||||
import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
|
||||
import AdminNextLayout from "@/app/ase-next/layout";
|
||||
import AdminNextPage from "@/app/ase-next/page";
|
||||
import HousekeepingDomainPage from "@/app/ase/[domain]/[[...segments]]/page";
|
||||
import HousekeepingDomainLayout from "@/app/ase/[domain]/layout";
|
||||
import HousekeepingLayout from "@/app/ase/layout";
|
||||
import HousekeepingPage from "@/app/ase/page";
|
||||
|
||||
const routeFiles = [
|
||||
"src/app/ase-next/layout.tsx",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase-next/[domain]/layout.tsx",
|
||||
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||
"src/app/ase/layout.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
] as const;
|
||||
|
||||
const forbiddenModuleRoots = [
|
||||
@@ -581,49 +575,20 @@ async function renderRoute(route: ReactNode | Promise<ReactNode>) {
|
||||
return renderToStaticMarkup(await route);
|
||||
}
|
||||
|
||||
describe("/ase-next preview gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
routeMocks.env.NODE_ENV = "test";
|
||||
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = true;
|
||||
describe("/ase canonical layout", () => {
|
||||
it("renders without a runtime preview gate", async () => {
|
||||
const html = await renderRoute(
|
||||
HousekeepingLayout({
|
||||
children: createElement("p", null, "Canonical child"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("Canonical child");
|
||||
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["production", true],
|
||||
["production", false],
|
||||
["development", false],
|
||||
] as const)("returns 404 for NODE_ENV=%s flag=%s", async (nodeEnv, flag) => {
|
||||
routeMocks.env.NODE_ENV = nodeEnv;
|
||||
routeMocks.env.HOUSEKEEPING_NEXT_PREVIEW_ENABLED = flag;
|
||||
|
||||
await expect(async () =>
|
||||
renderRoute(
|
||||
AdminNextLayout({
|
||||
children: createElement("p", null, "Preview child"),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(["development", "test"] as const)(
|
||||
"renders children in %s when explicitly enabled",
|
||||
async (nodeEnv) => {
|
||||
routeMocks.env.NODE_ENV = nodeEnv;
|
||||
|
||||
const html = await renderRoute(
|
||||
AdminNextLayout({
|
||||
children: createElement("p", null, "Preview child"),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain("Preview child");
|
||||
expect(routeMocks.notFound).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("/ase-next operational home", () => {
|
||||
describe("/ase operational home", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -633,13 +598,12 @@ describe("/ase-next operational home", () => {
|
||||
capabilityContext([PERMS.ADMIN_DASHBOARD, PERMS.USERS_VIEW]),
|
||||
);
|
||||
|
||||
const html = await renderRoute(AdminNextPage());
|
||||
const html = await renderRoute(HousekeepingPage());
|
||||
expect(html).toContain("Rendered operations.workspace");
|
||||
expect(html).toContain("HK::operations-workspace");
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
match: expect.objectContaining({ routeId: "operations.workspace" }),
|
||||
surface: "preview",
|
||||
}),
|
||||
);
|
||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||
@@ -654,10 +618,10 @@ describe("/ase-next operational home", () => {
|
||||
capabilityContext([PERMS.MOD_CFH_VIEW]),
|
||||
);
|
||||
|
||||
await expect(AdminNextPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/ase-next/people/users",
|
||||
await expect(HousekeepingPage()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/ase/people/users",
|
||||
);
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase-next/people/users");
|
||||
expect(routeMocks.redirect).toHaveBeenCalledWith("/ase/people/users");
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
@@ -668,7 +632,7 @@ describe("/ase-next operational home", () => {
|
||||
capabilityContext([]),
|
||||
);
|
||||
|
||||
await expect(AdminNextPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
await expect(HousekeepingPage()).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
expect(routeMocks.notFound).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.redirect).not.toHaveBeenCalled();
|
||||
expect(routeMocks.getHousekeepingCapabilityContext).toHaveBeenCalledTimes(
|
||||
@@ -677,14 +641,14 @@ describe("/ase-next operational home", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/ase-next/[domain] layout", () => {
|
||||
describe("/ase/[domain] layout", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects an unknown domain before loading capability context", async () => {
|
||||
await expect(
|
||||
AdminNextDomainLayout({
|
||||
HousekeepingDomainLayout({
|
||||
children: createElement("p", null, "Unknown body"),
|
||||
params: Promise.resolve({ domain: "unknown" }),
|
||||
}),
|
||||
@@ -699,7 +663,7 @@ describe("/ase-next/[domain] layout", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
AdminNextDomainLayout({
|
||||
HousekeepingDomainLayout({
|
||||
children: createElement("p", null, "Economy body"),
|
||||
params: Promise.resolve({ domain: "economy" }),
|
||||
}),
|
||||
@@ -716,7 +680,7 @@ describe("/ase-next/[domain] layout", () => {
|
||||
);
|
||||
|
||||
const html = await renderRoute(
|
||||
AdminNextDomainLayout({
|
||||
HousekeepingDomainLayout({
|
||||
children: createElement("p", null, "People body"),
|
||||
params: Promise.resolve({ domain: "people" }),
|
||||
}),
|
||||
@@ -727,7 +691,7 @@ describe("/ase-next/[domain] layout", () => {
|
||||
expect(html).toContain("HK::primary-navigation");
|
||||
expect(html).toContain("HK::contextual-navigation");
|
||||
expect(html).toContain("HK::command-disabled");
|
||||
expect(html).toContain("HK::preview-badge");
|
||||
expect(html).not.toContain("HK::preview-badge");
|
||||
expect(html).toContain("HK::back-to-site");
|
||||
expect(html).toContain("HK::people-title");
|
||||
expect(html).toContain("People body");
|
||||
@@ -748,7 +712,7 @@ describe("/ase-next/[domain] layout", () => {
|
||||
});
|
||||
|
||||
const html = await renderRoute(
|
||||
AdminNextDomainLayout({
|
||||
HousekeepingDomainLayout({
|
||||
children: createElement("p", null, "System body"),
|
||||
params: Promise.resolve({ domain: "system" }),
|
||||
}),
|
||||
@@ -780,17 +744,17 @@ describe("/ase-next/[domain] layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
describe("/ase/[domain]/[[...segments]] page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps a permitted preview path and renders its handler with one context", async () => {
|
||||
it("maps a permitted canonical path and renders its handler with one context", async () => {
|
||||
const context = capabilityContext([PERMS.MOD_CFH_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
const html = await renderRoute(
|
||||
AdminNextDomainPage({
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["users"] }),
|
||||
}),
|
||||
);
|
||||
@@ -802,7 +766,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledTimes(1);
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.users",
|
||||
@@ -814,12 +777,12 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
expect(routeMocks.getTranslations).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("maps nested preview segments to canonical dynamic parameters", async () => {
|
||||
it("maps nested canonical segments to canonical dynamic parameters", async () => {
|
||||
const context = capabilityContext([PERMS.USERS_VIEW]);
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
await renderRoute(
|
||||
AdminNextDomainPage({
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({
|
||||
domain: "people",
|
||||
segments: ["users", "42"],
|
||||
@@ -829,7 +792,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.user-detail",
|
||||
@@ -845,7 +807,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
routeMocks.getHousekeepingCapabilityContext.mockResolvedValue(context);
|
||||
|
||||
await renderRoute(
|
||||
AdminNextDomainPage({
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({
|
||||
domain: "people",
|
||||
segments: ["tools", "a+b[1]"],
|
||||
@@ -855,7 +817,6 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
|
||||
expect(routeMocks.renderHousekeepingRoute).toHaveBeenCalledWith({
|
||||
context,
|
||||
surface: "preview",
|
||||
translate: expect.any(Function),
|
||||
match: {
|
||||
routeId: "people.literal-tool",
|
||||
@@ -872,7 +833,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
AdminNextDomainPage({
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["bans"] }),
|
||||
}),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
@@ -884,7 +845,7 @@ describe("/ase-next/[domain]/[[...segments]] page", () => {
|
||||
|
||||
it("rejects an unknown route before loading capability context", async () => {
|
||||
await expect(
|
||||
AdminNextDomainPage({
|
||||
HousekeepingDomainPage({
|
||||
params: Promise.resolve({ domain: "people", segments: ["unknown"] }),
|
||||
}),
|
||||
).rejects.toThrow("NEXT_NOT_FOUND");
|
||||
@@ -908,247 +869,247 @@ describe("preview route import boundary", () => {
|
||||
it.each([
|
||||
[
|
||||
"relative database import with resolver extension",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../../lib/db.js";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased database import with resolver extension",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "@/lib/db.js";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"action root import with resolver extension",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import actions from "../../actions.mjs";',
|
||||
"src/actions",
|
||||
],
|
||||
[
|
||||
"legacy mod root import with resolver extension",
|
||||
"src/app/ase-next/layout.tsx",
|
||||
"src/app/ase/layout.tsx",
|
||||
'import mod from "../mod.cjs";',
|
||||
"src/app/mod",
|
||||
],
|
||||
[
|
||||
"database import with query suffix",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../../lib/db?server-only";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"action import with hash suffix",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import("../../actions/users#server")',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"Windows-style relative database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
String.raw`import db from "..\\..\\lib\\db";`,
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"Windows-style aliased database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
String.raw`import db from "@\\lib\\db";`,
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"percent-encoded database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../../lib/%64%62";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional CommonJS database require",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'require?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"module database require",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'module.require("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"require.resolve database access",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'require.resolve("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional module database require",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'module.require?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"optional require.resolve database access",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'require.resolve?.("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"TypeScript import-equals database access",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db = require("../../lib/db");',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"U+2028 line-continuation database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../\\' + "\u2028" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"U+2029 line-continuation database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../\\' + "\u2029" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"parenthesized dynamic action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import(("../../actions/users"))',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"regex-brace template-expression action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"CommonJS database require",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'require("../../lib/db")',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"template-literal dynamic action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
"import(`../../actions/users`)",
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"TypeScript-asserted dynamic action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import(("../../actions/users" as string))',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"template-expression dynamic action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
`const x = \`${interpolationOpen}import("../../actions/users")}\`;`,
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"nested template-expression dynamic action import",
|
||||
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
`const x = \`${interpolationOpen}ready ? \`${interpolationOpen}import("../../../../actions/nested")}\` : ""}\`;`,
|
||||
"src/actions/nested",
|
||||
],
|
||||
[
|
||||
"unicode escaped dynamic app-action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import("\\u002e\\u002e/actions/users")',
|
||||
"src/app/actions/users",
|
||||
],
|
||||
[
|
||||
"code-point escaped dynamic app-action import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import("\\u{2e}\\u{2e}/actions/users")',
|
||||
"src/app/actions/users",
|
||||
],
|
||||
[
|
||||
"hex escaped export-from auth import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'export * from "\\x2e\\x2e/\\x2e\\x2e/lib/auth";',
|
||||
"src/lib/auth",
|
||||
],
|
||||
[
|
||||
"escaped-slash permissions import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import permissions from "..\\/..\\/lib\\/permissions";',
|
||||
"src/lib/permissions",
|
||||
],
|
||||
[
|
||||
"unknown escape database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../../\\lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"line-continuation database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import db from "../\\' + "\n" + '../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"aliased database descendant import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import { query } from "@/lib/db/query";',
|
||||
"src/lib/db/query",
|
||||
],
|
||||
[
|
||||
"root relative database import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import { db } from "../../lib/db";',
|
||||
"src/lib/db",
|
||||
],
|
||||
[
|
||||
"domain relative auth side-effect import",
|
||||
"src/app/ase-next/[domain]/layout.tsx",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
'import "../../../lib/auth";',
|
||||
"src/lib/auth",
|
||||
],
|
||||
[
|
||||
"domain relative permissions export",
|
||||
"src/app/ase-next/[domain]/[[...segments]]/page.tsx",
|
||||
"src/app/ase/[domain]/[[...segments]]/page.tsx",
|
||||
'export { getAdminContext } from "../../../../lib/permissions";',
|
||||
"src/lib/permissions",
|
||||
],
|
||||
[
|
||||
"root relative action dynamic import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import("../../actions/users")',
|
||||
"src/actions/users",
|
||||
],
|
||||
[
|
||||
"root relative app action export",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'export * from "../actions";',
|
||||
"src/app/actions",
|
||||
],
|
||||
[
|
||||
"domain relative legacy admin import",
|
||||
"src/app/ase-next/[domain]/layout.tsx",
|
||||
"src/app/ase/[domain]/layout.tsx",
|
||||
'import page from "../../admin/users/page";',
|
||||
"src/app/admin/users/page",
|
||||
],
|
||||
[
|
||||
"root relative legacy mod dynamic import",
|
||||
"src/app/ase-next/layout.tsx",
|
||||
"src/app/ase/layout.tsx",
|
||||
'import("../mod/users/page")',
|
||||
"src/app/mod/users/page",
|
||||
],
|
||||
[
|
||||
"Prisma TypeScript import type",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'type PrismaClient = import("@prisma/client").PrismaClient;',
|
||||
"@prisma/client",
|
||||
],
|
||||
[
|
||||
"Drizzle package import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import { sql } from "drizzle-orm";',
|
||||
"drizzle-orm",
|
||||
],
|
||||
[
|
||||
"mysql2 package import",
|
||||
"src/app/ase-next/page.tsx",
|
||||
"src/app/ase/page.tsx",
|
||||
'import type { Pool } from "mysql2";',
|
||||
"mysql2",
|
||||
],
|
||||
@@ -1181,7 +1142,7 @@ describe("preview route import boundary", () => {
|
||||
],
|
||||
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
|
||||
expect(
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"),
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
|
||||
).toContain(violation);
|
||||
});
|
||||
|
||||
@@ -1190,7 +1151,7 @@ describe("preview route import boundary", () => {
|
||||
'import database from "@/lib/database.js?raw";',
|
||||
'import dbTools from "../../lib/db-tools.ts";',
|
||||
'import auth from "../../lib/authentication";',
|
||||
'import preview from "../ase-next-shared";',
|
||||
'import preview from "../ase-shared";',
|
||||
'import prismaTools from "@prisma/client-tools";',
|
||||
'import drizzleTools from "drizzle-orm-kit";',
|
||||
'import mysqlTools from "mysql2-wrapper";',
|
||||
@@ -1200,7 +1161,7 @@ describe("preview route import boundary", () => {
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase-next/page.tsx"),
|
||||
findRouteImportBoundaryViolations(source, "src/app/ase/page.tsx"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,6 @@ describe("housekeeping responsive contract", () => {
|
||||
]}
|
||||
labels={labels}
|
||||
search={vi.fn()}
|
||||
surface="canonical"
|
||||
>
|
||||
<div>Body</div>
|
||||
</HousekeepingShell>,
|
||||
@@ -74,7 +73,6 @@ describe("housekeeping responsive contract", () => {
|
||||
<CommandDeck
|
||||
labels={{ ...labels.commandDeck, trigger: "Search" }}
|
||||
search={vi.fn()}
|
||||
surface="canonical"
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
import { toHousekeepingHref } from "./href";
|
||||
import { HOUSEKEEPING_ROOT, toHousekeepingHref } from "./href";
|
||||
|
||||
describe("toHousekeepingHref", () => {
|
||||
it("keeps the canonical operations root unchanged", () => {
|
||||
expect(toHousekeepingHref("/ase", "canonical")).toBe("/ase");
|
||||
expect(HOUSEKEEPING_ROOT).toBe("/ase");
|
||||
expect(toHousekeepingHref("/ase")).toBe("/ase");
|
||||
});
|
||||
|
||||
it("maps a canonical domain route to the preview namespace", () => {
|
||||
expect(toHousekeepingHref("/ase/people/users", "preview")).toBe(
|
||||
"/ase-next/people/users",
|
||||
);
|
||||
it("keeps canonical domain routes unchanged", () => {
|
||||
expect(toHousekeepingHref("/ase/people/users")).toBe("/ase/people/users");
|
||||
});
|
||||
|
||||
it("rejects paths outside the canonical ase namespace", () => {
|
||||
expect(() =>
|
||||
toHousekeepingHref("/admin" as CanonicalHousekeepingHref, "canonical"),
|
||||
toHousekeepingHref("/admin" as CanonicalHousekeepingHref),
|
||||
).toThrow("invalid canonical housekeeping href");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,13 @@
|
||||
import type {
|
||||
CanonicalHousekeepingHref,
|
||||
HousekeepingSurface,
|
||||
} from "../contracts";
|
||||
import type { CanonicalHousekeepingHref } from "../contracts";
|
||||
|
||||
export const HOUSEKEEPING_ROOT = "/ase" as const;
|
||||
|
||||
export function toHousekeepingHref(
|
||||
href: CanonicalHousekeepingHref,
|
||||
surface: HousekeepingSurface,
|
||||
): CanonicalHousekeepingHref | `/ase-next${string}` {
|
||||
if (href !== "/ase" && !href.startsWith("/ase/")) {
|
||||
): CanonicalHousekeepingHref {
|
||||
if (href !== HOUSEKEEPING_ROOT && !href.startsWith(`${HOUSEKEEPING_ROOT}/`)) {
|
||||
throw new Error(`invalid canonical housekeeping href: ${href}`);
|
||||
}
|
||||
|
||||
return surface === "canonical"
|
||||
? href
|
||||
: (`/ase-next${href.slice(4)}` as `/ase-next${string}`);
|
||||
return href;
|
||||
}
|
||||
@@ -52,8 +52,8 @@ vi.mock("@/lib/db", () => {
|
||||
});
|
||||
vi.mock("@/lib/permissions", () => ({ getAdminContext }));
|
||||
|
||||
import AdminNextDomainLayout from "@/app/ase-next/[domain]/layout";
|
||||
import AdminNextPage from "@/app/ase-next/page";
|
||||
import AdminNextDomainLayout from "@/app/ase/[domain]/layout";
|
||||
import AdminNextPage from "@/app/ase/page";
|
||||
import { requireHousekeepingCapability } from "@/lib/admin/guard";
|
||||
import { auth as mockedAuth } from "@/lib/auth";
|
||||
import { anyCapability } from "./contracts";
|
||||
|
||||
@@ -31,7 +31,7 @@ const labels = {
|
||||
describe("CommandDeck", () => {
|
||||
it("renders an enabled accessible cmdk trigger", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CommandDeck labels={labels} search={vi.fn()} surface="preview" />,
|
||||
<CommandDeck labels={labels} search={vi.fn()} />,
|
||||
);
|
||||
expect(html).toContain("Search housekeeping");
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
@@ -64,16 +64,13 @@ describe("CommandDeck", () => {
|
||||
expect(nextCommandDeckIndex(0, 3, "previous")).toBe(2);
|
||||
});
|
||||
|
||||
it("restores trigger focus and projects canonical results to preview", () => {
|
||||
it("restores trigger focus and keeps canonical result hrefs", () => {
|
||||
const focus = vi.fn();
|
||||
restoreCommandDeckFocus({ focus });
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(projectHousekeepingSearchHref("/ase/people/users", "preview")).toBe(
|
||||
"/ase-next/people/users",
|
||||
expect(projectHousekeepingSearchHref("/ase/people/users")).toBe(
|
||||
"/ase/people/users",
|
||||
);
|
||||
expect(
|
||||
projectHousekeepingSearchHref("/ase/people/users", "canonical"),
|
||||
).toBe("/ase/people/users");
|
||||
});
|
||||
|
||||
it("keeps grouped result order and uses cmdk input/items in the client module", () => {
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import type {
|
||||
HousekeepingSearchResult,
|
||||
HousekeepingSurface,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingSearchResult } from "../contracts";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
import type {
|
||||
HousekeepingCommandHit,
|
||||
@@ -116,9 +113,8 @@ export function restoreCommandDeckFocus(
|
||||
|
||||
export function projectHousekeepingSearchHref(
|
||||
href: HousekeepingNavigationHit["href"],
|
||||
surface: HousekeepingSurface,
|
||||
) {
|
||||
return toHousekeepingHref(href, surface);
|
||||
return toHousekeepingHref(href);
|
||||
}
|
||||
|
||||
export function flattenHousekeepingSearchResults(
|
||||
@@ -143,11 +139,9 @@ export function flattenHousekeepingSearchResults(
|
||||
export function CommandDeck({
|
||||
labels,
|
||||
search,
|
||||
surface,
|
||||
}: {
|
||||
readonly labels: CommandDeckLabels;
|
||||
readonly search: HousekeepingSearchExecutor;
|
||||
readonly surface: HousekeepingSurface;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState("");
|
||||
@@ -225,11 +219,11 @@ export function CommandDeck({
|
||||
|
||||
const activate = useCallback(
|
||||
(entry: CommandDeckEntry) => {
|
||||
const href = projectHousekeepingSearchHref(entry.hit.href, surface);
|
||||
const href = projectHousekeepingSearchHref(entry.hit.href);
|
||||
close();
|
||||
window.location.assign(href);
|
||||
},
|
||||
[close, surface],
|
||||
[close],
|
||||
);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HousekeepingSurface } from "../contracts";
|
||||
import {
|
||||
CommandDeck,
|
||||
type CommandDeckLabels,
|
||||
@@ -9,20 +8,8 @@ interface CommandTriggerProps {
|
||||
label: string;
|
||||
labels: Omit<CommandDeckLabels, "trigger">;
|
||||
search: HousekeepingSearchExecutor;
|
||||
surface: HousekeepingSurface;
|
||||
}
|
||||
|
||||
export function CommandTrigger({
|
||||
label,
|
||||
labels,
|
||||
search,
|
||||
surface,
|
||||
}: CommandTriggerProps) {
|
||||
return (
|
||||
<CommandDeck
|
||||
labels={{ trigger: label, ...labels }}
|
||||
search={search}
|
||||
surface={surface}
|
||||
/>
|
||||
);
|
||||
export function CommandTrigger({ label, labels, search }: CommandTriggerProps) {
|
||||
return <CommandDeck labels={{ trigger: label, ...labels }} search={search} />;
|
||||
}
|
||||
@@ -18,10 +18,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type {
|
||||
HousekeepingRouteDefinition,
|
||||
HousekeepingSurface,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingRouteDefinition } from "../contracts";
|
||||
import type { HousekeepingPreferences } from "../preferences/schema";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
import {
|
||||
@@ -73,14 +70,12 @@ function orderedPinnedRouteIds(preferences: HousekeepingPreferences): string[] {
|
||||
function SortableFavorite({
|
||||
route,
|
||||
title,
|
||||
surface,
|
||||
unpin,
|
||||
unpinLabel,
|
||||
reorderInstructionsId,
|
||||
}: {
|
||||
readonly route: HousekeepingRouteDefinition;
|
||||
readonly title: string;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly unpin: () => void;
|
||||
readonly unpinLabel: string;
|
||||
readonly reorderInstructionsId: string;
|
||||
@@ -107,7 +102,7 @@ function SortableFavorite({
|
||||
</button>
|
||||
<Link
|
||||
className="min-w-0 flex-1 break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
href={toHousekeepingHref(route.href, surface)}
|
||||
href={toHousekeepingHref(route.href)}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
@@ -133,14 +128,12 @@ export interface FavoritesLabels {
|
||||
export function Favorites({
|
||||
preferences: initialPreferences,
|
||||
routes,
|
||||
surface,
|
||||
savePreferences,
|
||||
labels,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly preferences: HousekeepingPreferences;
|
||||
readonly routes: readonly HousekeepingRouteDefinition[];
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly savePreferences: HousekeepingPreferenceSaver;
|
||||
readonly labels: FavoritesLabels;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
@@ -219,7 +212,6 @@ export function Favorites({
|
||||
key={id}
|
||||
route={route}
|
||||
title={titles[route.labelKey] ?? route.id}
|
||||
surface={surface}
|
||||
unpinLabel={labels.unpin}
|
||||
reorderInstructionsId="housekeeping-favorites-reorder-instructions"
|
||||
unpin={() => void commit(togglePinnedRoute(preferences, id))}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HousekeepingSurface } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { HousekeepingShell } from "./housekeeping-shell";
|
||||
|
||||
@@ -15,7 +14,6 @@ const labels = {
|
||||
primaryNavigation: "Primary housekeeping navigation",
|
||||
contextualNavigation: "Operations navigation",
|
||||
command: "Search housekeeping",
|
||||
preview: "Preview",
|
||||
backToSite: "Back to site",
|
||||
operatorRank: "Rank 6",
|
||||
commandDeck: {
|
||||
@@ -33,17 +31,15 @@ const labels = {
|
||||
const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
{
|
||||
id: "operations",
|
||||
href: "/ase-next/operations",
|
||||
href: "/ase/operations",
|
||||
iconId: "inbox",
|
||||
label: "Operations",
|
||||
description: "Manage operations",
|
||||
items: [
|
||||
{ id: "queue", href: "/ase-next/operations/queue", label: "Queue" },
|
||||
],
|
||||
items: [{ id: "queue", href: "/ase/operations/queue", label: "Queue" }],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
href: "/ase-next/people",
|
||||
href: "/ase/people",
|
||||
iconId: "users",
|
||||
label: "People",
|
||||
description: "Manage people",
|
||||
@@ -51,7 +47,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
href: "/ase-next/content",
|
||||
href: "/ase/content",
|
||||
iconId: "file-text",
|
||||
label: "Content",
|
||||
description: "Manage content",
|
||||
@@ -59,7 +55,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
href: "/ase-next/economy",
|
||||
href: "/ase/economy",
|
||||
iconId: "gem",
|
||||
label: "Economy",
|
||||
description: "Manage economy",
|
||||
@@ -67,7 +63,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
href: "/ase-next/hotel",
|
||||
href: "/ase/hotel",
|
||||
iconId: "hotel",
|
||||
label: "Hotel",
|
||||
description: "Manage hotel",
|
||||
@@ -75,7 +71,7 @@ const domains: readonly HousekeepingNavigationDomain[] = [
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
href: "/ase-next/system",
|
||||
href: "/ase/system",
|
||||
iconId: "settings",
|
||||
label: "System",
|
||||
description: "Manage system",
|
||||
@@ -149,7 +145,6 @@ const componentBoundaryRules = [
|
||||
function renderShell(
|
||||
activeDomainId: HousekeepingNavigationDomain["id"] = "operations",
|
||||
availableDomains: readonly HousekeepingNavigationDomain[] = domains,
|
||||
surface: HousekeepingSurface = "preview",
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<HousekeepingShell
|
||||
@@ -158,7 +153,6 @@ function renderShell(
|
||||
domains={availableDomains}
|
||||
labels={labels}
|
||||
search={vi.fn()}
|
||||
surface={surface}
|
||||
>
|
||||
<p>Deck body</p>
|
||||
</HousekeepingShell>,
|
||||
@@ -197,11 +191,11 @@ describe("HousekeepingShell", () => {
|
||||
expect(html).toContain('<nav aria-label="Operations navigation"');
|
||||
expect(html).toContain('<main id="housekeeping-content"');
|
||||
expect(html).toContain(">Deck body</p>");
|
||||
expect(html).toContain('href="/ase-next/operations/queue"');
|
||||
expect(html).toContain('href="/ase/operations/queue"');
|
||||
expect(html).toContain(">Queue<");
|
||||
expect(html).toContain(">Nora<");
|
||||
expect(html).toContain(">6<");
|
||||
expect(html).toContain(">Preview<");
|
||||
expect(html).not.toContain(">Preview<");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain(">Back to site<");
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
@@ -215,7 +209,7 @@ describe("HousekeepingShell", () => {
|
||||
const activeAnchors = allAnchors.filter((anchor) =>
|
||||
anchor.includes('aria-current="page"'),
|
||||
);
|
||||
const peopleAnchor = anchorForHref(html, "/ase-next/people");
|
||||
const peopleAnchor = anchorForHref(html, "/ase/people");
|
||||
for (const iconClass of [
|
||||
"lucide-inbox",
|
||||
"lucide-users",
|
||||
@@ -235,11 +229,8 @@ describe("HousekeepingShell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only marks the preview surface as preview", () => {
|
||||
expect(renderShell()).toContain(">Preview<");
|
||||
expect(renderShell("operations", domains, "canonical")).not.toContain(
|
||||
">Preview<",
|
||||
);
|
||||
it("does not render a preview-only badge on the canonical surface", () => {
|
||||
expect(renderShell()).not.toContain(">Preview<");
|
||||
});
|
||||
|
||||
it("does not throw or render contextual items when a valid active domain is absent", () => {
|
||||
@@ -248,7 +239,7 @@ describe("HousekeepingShell", () => {
|
||||
);
|
||||
expect(() => renderShell("system", domainsWithoutSystem)).not.toThrow();
|
||||
const html = renderShell("system", domainsWithoutSystem);
|
||||
expect(html).not.toContain('href="/ase-next/operations/queue"');
|
||||
expect(html).not.toContain('href="/ase/operations/queue"');
|
||||
expect(html).not.toContain(">Queue<");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { HousekeepingActor, HousekeepingSurface } from "../contracts";
|
||||
import type { HousekeepingActor } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import type { HousekeepingSearchExecutor } from "./command-deck";
|
||||
import { CommandTrigger } from "./command-trigger";
|
||||
@@ -25,7 +25,6 @@ export interface HousekeepingShellProps {
|
||||
primaryNavigation: string;
|
||||
contextualNavigation: string;
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
operatorRank: string;
|
||||
commandDeck: {
|
||||
@@ -39,7 +38,6 @@ export interface HousekeepingShellProps {
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
surface?: HousekeepingSurface;
|
||||
search: HousekeepingSearchExecutor;
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -50,7 +48,6 @@ export function HousekeepingShell({
|
||||
domains,
|
||||
labels,
|
||||
search,
|
||||
surface = "preview",
|
||||
children,
|
||||
}: HousekeepingShellProps) {
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
@@ -68,11 +65,6 @@ export function HousekeepingShell({
|
||||
</a>
|
||||
<header className="flex min-w-0 flex-wrap items-center justify-between gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{surface === "preview" ? (
|
||||
<span className="rounded-full border border-[var(--admin-border)] px-2 py-1 text-xs text-[var(--admin-accent)]">
|
||||
{labels.preview}
|
||||
</span>
|
||||
) : null}
|
||||
<OperatorSummary actor={actor} rankLabel={labels.operatorRank} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -80,7 +72,6 @@ export function HousekeepingShell({
|
||||
label={labels.command}
|
||||
labels={labels.commandDeck}
|
||||
search={search}
|
||||
surface={surface}
|
||||
/>
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
@@ -73,7 +73,6 @@ describe("OperationalInbox", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
labels={labels}
|
||||
surface="preview"
|
||||
response={{
|
||||
items: [item("ticket-1")],
|
||||
errors: [{ sourceId: "content.publication", code: "TIMEOUT" }],
|
||||
@@ -88,7 +87,7 @@ describe("OperationalInbox", () => {
|
||||
expect(html).not.toContain("content.publication");
|
||||
expect(html).not.toContain("TIMEOUT");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).toContain('href="/ase/people/users"');
|
||||
expect(html).not.toContain("Dismiss");
|
||||
expect(html).not.toContain("Assign");
|
||||
});
|
||||
@@ -100,19 +99,10 @@ describe("OperationalInbox", () => {
|
||||
correlationId: "inbox-empty",
|
||||
} as const;
|
||||
const loading = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
labels={labels}
|
||||
surface="preview"
|
||||
response={response}
|
||||
loading
|
||||
/>,
|
||||
<OperationalInbox labels={labels} response={response} loading />,
|
||||
);
|
||||
const empty = renderToStaticMarkup(
|
||||
<OperationalInbox
|
||||
labels={labels}
|
||||
surface="preview"
|
||||
response={response}
|
||||
/>,
|
||||
<OperationalInbox labels={labels} response={response} />,
|
||||
);
|
||||
expect(loading).toContain('role="status"');
|
||||
expect(loading).toContain("Loading operational work");
|
||||
@@ -120,11 +110,8 @@ describe("OperationalInbox", () => {
|
||||
});
|
||||
|
||||
it("projects canonical hrefs without changing production destinations", () => {
|
||||
expect(
|
||||
projectOperationalInboxHref("/ase/system/operations/alerts", "preview"),
|
||||
).toBe("/ase-next/system/operations/alerts");
|
||||
expect(
|
||||
projectOperationalInboxHref("/ase/system/operations/alerts", "canonical"),
|
||||
).toBe("/ase/system/operations/alerts");
|
||||
expect(projectOperationalInboxHref("/ase/system/operations/alerts")).toBe(
|
||||
"/ase/system/operations/alerts",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { useMemo, useState } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type {
|
||||
CanonicalHousekeepingHref,
|
||||
HousekeepingSurface,
|
||||
HousekeepingWorkItem,
|
||||
} from "../contracts";
|
||||
import type { HousekeepingInboxResponse } from "../inbox/inbox-service";
|
||||
@@ -40,11 +39,8 @@ export function filterOperationalInboxItems(
|
||||
);
|
||||
}
|
||||
|
||||
export function projectOperationalInboxHref(
|
||||
href: CanonicalHousekeepingHref,
|
||||
surface: HousekeepingSurface,
|
||||
) {
|
||||
return toHousekeepingHref(href, surface);
|
||||
export function projectOperationalInboxHref(href: CanonicalHousekeepingHref) {
|
||||
return toHousekeepingHref(href);
|
||||
}
|
||||
|
||||
function unique(values: readonly string[]): readonly string[] {
|
||||
@@ -54,13 +50,11 @@ function unique(values: readonly string[]): readonly string[] {
|
||||
export function OperationalInbox({
|
||||
response,
|
||||
labels,
|
||||
surface,
|
||||
loading = false,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly response: HousekeepingInboxResponse;
|
||||
readonly labels: OperationalInboxLabels;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly loading?: boolean;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
@@ -172,7 +166,7 @@ export function OperationalInbox({
|
||||
</time>
|
||||
</div>
|
||||
<Link
|
||||
href={projectOperationalInboxHref(item.href, surface)}
|
||||
href={projectOperationalInboxHref(item.href)}
|
||||
className="rounded-md border border-[var(--admin-border)] px-3 py-2 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{labels.open}
|
||||
|
||||
@@ -150,7 +150,6 @@ describe("housekeeping personalization", () => {
|
||||
<Favorites
|
||||
preferences={initialPreferences}
|
||||
routes={routes}
|
||||
surface="preview"
|
||||
savePreferences={save}
|
||||
labels={{
|
||||
title: "Favorites",
|
||||
@@ -170,7 +169,6 @@ describe("housekeeping personalization", () => {
|
||||
source: "route-visit",
|
||||
},
|
||||
]}
|
||||
surface="preview"
|
||||
labels={{ title: "Recent work", empty: "Nothing recent" }}
|
||||
/>
|
||||
<WidgetSettings
|
||||
@@ -196,7 +194,7 @@ describe("housekeeping personalization", () => {
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(html).toContain('href="/ase-next/people/users"');
|
||||
expect(html).toContain('href="/ase/people/users"');
|
||||
expect(html).toContain("Required");
|
||||
expect(html).toContain('disabled=""');
|
||||
expect(html).toContain("Some widgets failed");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Link from "@/components/link";
|
||||
import type { HousekeepingSurface } from "../contracts";
|
||||
import type { HousekeepingRecentItem } from "../recent/recent-work";
|
||||
import { toHousekeepingHref } from "../routing/href";
|
||||
|
||||
@@ -11,12 +10,10 @@ export interface RecentWorkLabels {
|
||||
export function RecentWork({
|
||||
items,
|
||||
labels,
|
||||
surface,
|
||||
titles = {},
|
||||
}: {
|
||||
readonly items: readonly HousekeepingRecentItem[];
|
||||
readonly labels: RecentWorkLabels;
|
||||
readonly surface: HousekeepingSurface;
|
||||
readonly titles?: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
return (
|
||||
@@ -35,7 +32,7 @@ export function RecentWork({
|
||||
className="min-w-0 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3"
|
||||
>
|
||||
<Link
|
||||
href={toHousekeepingHref(item.canonicalHref, surface)}
|
||||
href={toHousekeepingHref(item.canonicalHref)}
|
||||
className="break-words rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--admin-accent)]"
|
||||
>
|
||||
{titles[item.labelKey] ?? item.routeId}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { contentMigrationEntries } from "./content";
|
||||
import { ownedLegacyPages } from "./discover-legacy-pages";
|
||||
import { ownedLegacyPages, recordedLegacyPages } from "./discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
const CONTENT_PREFIXES = [
|
||||
@@ -24,7 +25,10 @@ const CONTENT_PREFIXES = [
|
||||
|
||||
describe("contentMigrationEntries", () => {
|
||||
it("covers every content page without legacy-domain targets", () => {
|
||||
const expected = ownedLegacyPages(CONTENT_PREFIXES);
|
||||
const expected = ownedLegacyPages(
|
||||
CONTENT_PREFIXES,
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
);
|
||||
expect(validateMigrationEntries(expected, contentMigrationEntries)).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -26,37 +26,8 @@ function createRouteTree(files: readonly string[]): string {
|
||||
}
|
||||
|
||||
describe("discoverLegacyPages", () => {
|
||||
it("discovers the exact legacy administration inventory", () => {
|
||||
const pages = discoverLegacyPages();
|
||||
|
||||
expect(pages).toHaveLength(137);
|
||||
expect(pages).toContainEqual({
|
||||
surface: "admin",
|
||||
legacyPath: "/admin/users/:id/edit",
|
||||
sourceFile: "src/app/admin/users/[id]/edit/page.tsx",
|
||||
});
|
||||
expect(pages).toContainEqual({
|
||||
surface: "mod",
|
||||
legacyPath: "/mod/cfh/:id",
|
||||
sourceFile: "src/app/mod/cfh/[id]/page.tsx",
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers root administration and moderation routes", () => {
|
||||
expect(discoverLegacyPages()).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
surface: "admin",
|
||||
legacyPath: "/admin",
|
||||
sourceFile: "src/app/admin/page.tsx",
|
||||
},
|
||||
{
|
||||
surface: "mod",
|
||||
legacyPath: "/mod",
|
||||
sourceFile: "src/app/mod/page.tsx",
|
||||
},
|
||||
]),
|
||||
);
|
||||
it("returns no routes after the atomic cutover", () => {
|
||||
expect(discoverLegacyPages()).toEqual([]);
|
||||
});
|
||||
|
||||
it("discovers root routes in a controlled route tree", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import type { LegacyPage, LegacySurface } from "./types";
|
||||
|
||||
@@ -23,6 +23,7 @@ function discoverSurface(
|
||||
): LegacyPage[] {
|
||||
const surfaceDirectory = join(rootDir, "src", "app", surface);
|
||||
const pages: LegacyPage[] = [];
|
||||
if (!existsSync(surfaceDirectory)) return pages;
|
||||
|
||||
function walk(directory: string): void {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
@@ -51,6 +52,22 @@ function discoverSurface(
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function recordedLegacyPages(
|
||||
entries: readonly LegacyPage[],
|
||||
): LegacyPage[] {
|
||||
return entries
|
||||
.map(({ surface, legacyPath, sourceFile }) => ({
|
||||
surface,
|
||||
legacyPath,
|
||||
sourceFile,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
`${a.surface}:${a.legacyPath}:${a.sourceFile}`.localeCompare(
|
||||
`${b.surface}:${b.legacyPath}:${b.sourceFile}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function discoverLegacyPages(rootDir = process.cwd()): LegacyPage[] {
|
||||
return (["admin", "mod"] as const)
|
||||
.flatMap((surface) => discoverSurface(rootDir, surface))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ownedLegacyPages } from "./discover-legacy-pages";
|
||||
import { ownedLegacyPages, recordedLegacyPages } from "./discover-legacy-pages";
|
||||
import { economyMigrationEntries } from "./economy";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
const ECONOMY_PREFIXES = [
|
||||
@@ -20,7 +21,10 @@ const ECONOMY_PREFIXES = [
|
||||
|
||||
describe("economyMigrationEntries", () => {
|
||||
it("covers every economy page without legacy-domain targets", () => {
|
||||
const expected = ownedLegacyPages(ECONOMY_PREFIXES);
|
||||
const expected = ownedLegacyPages(
|
||||
ECONOMY_PREFIXES,
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
);
|
||||
expect(validateMigrationEntries(expected, economyMigrationEntries)).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { ownedLegacyPages } from "./discover-legacy-pages";
|
||||
import { ownedLegacyPages, recordedLegacyPages } from "./discover-legacy-pages";
|
||||
import { hotelMigrationEntries } from "./hotel";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
const HOTEL_PREFIXES = [
|
||||
@@ -15,7 +16,10 @@ describe("hotelMigrationEntries", () => {
|
||||
it("maps all hotel tools below the hotel target root", () => {
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(HOTEL_PREFIXES),
|
||||
ownedLegacyPages(
|
||||
HOTEL_PREFIXES,
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
),
|
||||
hotelMigrationEntries,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discoverLegacyPages } from "./discover-legacy-pages";
|
||||
import {
|
||||
discoverLegacyPages,
|
||||
recordedLegacyPages,
|
||||
} from "./discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
|
||||
it("covers all 137 legacy pages exactly once", () => {
|
||||
it("keeps all 137 historical pages recorded after cutover", () => {
|
||||
const discovered = discoverLegacyPages();
|
||||
const recorded = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX);
|
||||
|
||||
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(137);
|
||||
expect(discovered).toEqual([]);
|
||||
expect(
|
||||
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
validateMigrationEntries(recorded, HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { discoverLegacyPages } from "./discover-legacy-pages";
|
||||
import { recordedLegacyPages } from "./discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { operationsMigrationEntries } from "./operations";
|
||||
import { peopleMigrationEntries } from "./people";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
@@ -52,7 +53,7 @@ describe("peopleMigrationEntries", () => {
|
||||
});
|
||||
|
||||
it("covers every Operations and People legacy page exactly once", () => {
|
||||
const expected = discoverLegacyPages().filter(
|
||||
const expected = recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX).filter(
|
||||
(page) =>
|
||||
page.legacyPath === "/admin" ||
|
||||
PEOPLE_PREFIXES.some(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import { ownedLegacyPages } from "./discover-legacy-pages";
|
||||
import { ownedLegacyPages, recordedLegacyPages } from "./discover-legacy-pages";
|
||||
import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
|
||||
import { systemMigrationEntries } from "./system";
|
||||
import { validateMigrationEntries } from "./validate-matrix";
|
||||
|
||||
@@ -23,7 +24,10 @@ describe("systemMigrationEntries", () => {
|
||||
expect(systemMigrationEntries).toHaveLength(19);
|
||||
expect(
|
||||
validateMigrationEntries(
|
||||
ownedLegacyPages(SYSTEM_PREFIXES),
|
||||
ownedLegacyPages(
|
||||
SYSTEM_PREFIXES,
|
||||
recordedLegacyPages(HOUSEKEEPING_MIGRATION_MATRIX),
|
||||
),
|
||||
systemMigrationEntries,
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
@@ -5,10 +5,7 @@ import { HOTEL_ROUTE_HANDLERS } from "./domains/hotel/route-handlers";
|
||||
import { OPERATIONS_ROUTE_HANDLERS } from "./domains/operations/route-handlers";
|
||||
import { PEOPLE_ROUTE_HANDLERS } from "./domains/people/route-handlers";
|
||||
import { SYSTEM_ROUTE_HANDLERS } from "./domains/system/route-handlers";
|
||||
import type {
|
||||
HousekeepingCapabilityContext,
|
||||
HousekeepingSurface,
|
||||
} from "./foundation/contracts";
|
||||
import type { HousekeepingCapabilityContext } from "./foundation/contracts";
|
||||
import type { HousekeepingRouteMatch } from "./foundation/routing/match-route";
|
||||
import type { HousekeepingPreferenceSaver } from "./foundation/shell/widget-settings";
|
||||
|
||||
@@ -18,7 +15,6 @@ export interface HousekeepingPageInput {
|
||||
readonly searchParams?: Readonly<
|
||||
Record<string, string | readonly string[] | undefined>
|
||||
>;
|
||||
readonly surface?: HousekeepingSurface;
|
||||
readonly savePreferences?: HousekeepingPreferenceSaver;
|
||||
readonly translate?: (key: string) => string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user