feat(housekeeping): cut over administration to ase

This commit is contained in:
Simo committed 2026-08-30 20:35:22 +02:00
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([]);
+1 -5
View File
@@ -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;
}