ccf10af5bd
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
391 lines
18 KiB
TypeScript
391 lines
18 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import fs from "node:fs";
|
||
import path from "node:path";
|
||
import test from "node:test";
|
||
import { fileURLToPath } from "node:url";
|
||
|
||
import { messageFromError } from "../src/composables/useForm.ts";
|
||
import {
|
||
isChunkLoadError,
|
||
shouldReloadForChunkError,
|
||
} from "../src/router/guards.ts";
|
||
import { resolveDocumentTitle } from "../src/router/title.ts";
|
||
|
||
const rootDir = path.resolve(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
"..",
|
||
);
|
||
|
||
test("R6 route meta title and chunk recovery match Sub2API navigation pattern", () => {
|
||
assert.equal(resolveDocumentTitle("Gate"), "Gate - HWLAB Cloud Web");
|
||
assert.equal(resolveDocumentTitle(" "), "平台控制台 - HWLAB Cloud Web");
|
||
assert.equal(
|
||
isChunkLoadError(new Error("Failed to fetch dynamically imported module")),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isChunkLoadError(new Error("Loading CSS chunk 42 failed")),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isChunkLoadError(
|
||
new Error("Unable to preload CSS for /assets/MdtodoView.css"),
|
||
),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
isChunkLoadError(new Error("ordinary route guard failure")),
|
||
false,
|
||
);
|
||
|
||
const memory = new Map<string, string>();
|
||
const storage = {
|
||
getItem: (key: string) => memory.get(key) ?? null,
|
||
setItem: (key: string, value: string) => {
|
||
memory.set(key, value);
|
||
},
|
||
};
|
||
assert.equal(shouldReloadForChunkError(100_000, storage), true);
|
||
assert.equal(shouldReloadForChunkError(105_000, storage), false);
|
||
assert.equal(shouldReloadForChunkError(111_000, storage), true);
|
||
});
|
||
|
||
test("R6 useForm normalizes nested API errors for toast/error surfaces", () => {
|
||
assert.equal(messageFromError(new Error("plain failure")), "plain failure");
|
||
assert.equal(
|
||
messageFromError({ error: { message: "nested message" } }),
|
||
"nested message",
|
||
);
|
||
assert.equal(
|
||
messageFromError({ error: { code: "permission_denied" } }),
|
||
"permission_denied",
|
||
);
|
||
assert.equal(
|
||
messageFromError({ reason: "blocked_by_gate" }),
|
||
"blocked_by_gate",
|
||
);
|
||
});
|
||
|
||
test("R6 common module map keeps HWLAB views on shared console primitives", () => {
|
||
assert.equal(
|
||
fs.existsSync(path.join(rootDir, "src/components/common/DataTable.vue")),
|
||
false,
|
||
"legacy DataTable should stay removed after DataGrid migration",
|
||
);
|
||
const expectations: Array<[string, string[]]> = [
|
||
[
|
||
"src/views/admin/ProviderProfilesView.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"DataGrid",
|
||
"ResourceCollection",
|
||
"BaseDialog",
|
||
"ConfirmDialog",
|
||
"TablePageLayout",
|
||
"useTableLoader",
|
||
"useForm",
|
||
],
|
||
],
|
||
[
|
||
"src/components/access/AccessMatrixPanel.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"OperationsWorkspace",
|
||
"AccessUserIndex",
|
||
"AccessPermissionWorkspace",
|
||
"AccessUserInspector",
|
||
],
|
||
],
|
||
[
|
||
"src/views/admin/SecretsView.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"OperationsWorkspace",
|
||
"SecretInventoryIndex",
|
||
"SecretInventoryWorkspace",
|
||
],
|
||
],
|
||
[
|
||
"src/views/admin/UsersView.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"OperationsWorkspace",
|
||
"UserDirectoryIndex",
|
||
"UserDirectoryCollection",
|
||
"UserAccountInspector",
|
||
"ViewModeSwitch",
|
||
],
|
||
],
|
||
[
|
||
"src/views/admin/BillingView.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"OperationsWorkspace",
|
||
"BillingCodeIndex",
|
||
"BillingCodeInspector",
|
||
"ViewModeSwitch",
|
||
],
|
||
],
|
||
[
|
||
"src/views/GateView.vue",
|
||
["AsyncBoundary", "GateWorkspace", "useTableLoader"],
|
||
],
|
||
[
|
||
"src/views/PerformanceView.vue",
|
||
["AsyncBoundary", "PerformanceOverview", "PerformanceDetailGrid", "ViewModeSwitch", "useTableLoader"],
|
||
],
|
||
[
|
||
"src/views/SkillsView.vue",
|
||
[
|
||
"AsyncBoundary",
|
||
"DataGrid",
|
||
"ResourceCollection",
|
||
"VirtualList",
|
||
"BaseDialog",
|
||
"TablePageLayout",
|
||
"useTableLoader",
|
||
"useForm",
|
||
],
|
||
],
|
||
];
|
||
|
||
for (const [relativePath, terms] of expectations) {
|
||
const source = read(relativePath);
|
||
for (const term of terms)
|
||
assert.match(
|
||
source,
|
||
new RegExp(`\\b${term}\\b`, "u"),
|
||
`${relativePath} should use ${term}`,
|
||
);
|
||
assert.doesNotMatch(
|
||
source,
|
||
/<table\s/u,
|
||
`${relativePath} should not regress to local raw table markup`,
|
||
);
|
||
if (
|
||
/admin\/(?:Secrets|Users|Billing)View|access\/AccessMatrixPanel/u.test(
|
||
relativePath,
|
||
)
|
||
)
|
||
assert.doesNotMatch(
|
||
source,
|
||
/view-toggle|admin-user-glyph|billing-glyph|BoundedWorkspace|TablePageLayout/u,
|
||
`${relativePath} should not restore private legacy layout or glyph implementations`,
|
||
);
|
||
}
|
||
});
|
||
|
||
test("R6 console semantics keep real icons, native entity lists, and dynamic access columns", () => {
|
||
assert.equal(fs.existsSync(path.join(rootDir, "src/components/console/ConsoleIcon.vue")), false);
|
||
assert.match(read("package.json"), /"lucide-vue-next"/u);
|
||
const viewSwitchSource = read("src/components/console/ViewModeSwitch.vue");
|
||
assert.match(viewSwitchSource, /type \{ Component \}/u);
|
||
assert.match(viewSwitchSource, /<component :is="option\.icon"/u);
|
||
const entityIndexSource = read("src/components/console/EntityIndex.vue");
|
||
assert.match(entityIndexSource, /<ul\b/u);
|
||
assert.match(entityIndexSource, /<li\b/u);
|
||
assert.doesNotMatch(entityIndexSource, /role="listbox"|role="option"/u);
|
||
const accessSource = read("src/components/access/AccessMatrixPanel.vue");
|
||
assert.match(accessSource, /--permission-tool-count/u);
|
||
assert.doesNotMatch(accessSource, /repeat\(6,/u);
|
||
assert.match(accessSource, /if \(!value\) access\.clearSelection\(\)/u);
|
||
assert.match(
|
||
accessSource,
|
||
/function closeInspector\(\): void \{\s*access\.clearSelection\(\);/u,
|
||
);
|
||
const accessStoreSource = read("src/stores/access.ts");
|
||
assert.match(accessStoreSource, /function clearSelection\(\): void/u);
|
||
assert.match(accessStoreSource, /selectedUserId\.value = null/u);
|
||
assert.match(accessStoreSource, /detail\.value = null/u);
|
||
const accessWorkspaceSource = read("src/components/access/AccessPermissionWorkspace.vue");
|
||
assert.doesNotMatch(accessWorkspaceSource, /<button[^>]+role="row"/u);
|
||
assert.match(accessWorkspaceSource, /role="grid"/u);
|
||
assert.match(accessWorkspaceSource, /role="row"/u);
|
||
assert.match(accessWorkspaceSource, /role="gridcell"/u);
|
||
const accessViewSource = read("src/stores/admin-access-view.ts");
|
||
assert.match(accessViewSource, /interface AccessOpenFgaView/u);
|
||
assert.match(accessViewSource, /interface AccessTupleView/u);
|
||
assert.doesNotMatch(read("src/components/access/AccessUserInspector.vue"), /Record<string, unknown>/u);
|
||
assert.doesNotMatch(read("src/components/access/AccessPermissionWorkspace.vue"), /Record<string, unknown>/u);
|
||
});
|
||
|
||
test("R6 MDTODO task rows expose opaque task references", () => {
|
||
const taskTreeSource = read("src/components/mdtodo/MdtodoTaskTree.vue");
|
||
assert.match(taskTreeSource, /:data-task-ref="task\.taskRef"/u);
|
||
});
|
||
|
||
test("R2 console rewrite owns the final visual layer and removes private letter glyphs", () => {
|
||
const mainSource = read("src/main.ts");
|
||
assert.match(mainSource, /console-shell\.css/u);
|
||
assert.match(mainSource, /console-r2\.css/u);
|
||
assert.doesNotMatch(mainSource, /console-legacy\.css/u);
|
||
|
||
const migratedViews: Array<[string, string]> = [
|
||
["src/components/common/EmptyState.vue", "lucide-vue-next"],
|
||
["src/views/SkillsView.vue", "EntityCard"],
|
||
["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
|
||
["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
|
||
["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
|
||
];
|
||
for (const [relativePath, sharedComponent] of migratedViews) {
|
||
const source = read(relativePath);
|
||
assert.match(source, new RegExp(sharedComponent, "u"), `${relativePath} should use ${sharedComponent}`);
|
||
assert.doesNotMatch(source, />\s*(?:SK|PJ|AI|KEY|HW)\s*</u, `${relativePath} should not render private letter glyphs`);
|
||
}
|
||
|
||
const r2Styles = read("src/styles/console-r2.css");
|
||
for (const selector of [".platform-sidebar", ".page-command-bar", ".console-status-strip", ".console-data-grid", ".pcb-card", ".mdtodo-workspace"])
|
||
assert.match(r2Styles, new RegExp(selector.replaceAll(".", "\\."), "u"), `R2 visual layer should own ${selector}`);
|
||
assert.match(r2Styles, /@media \(max-width: 1100px\)/u);
|
||
assert.match(r2Styles, /@media \(max-width: 760px\)/u);
|
||
assert.match(r2Styles, /@media \(max-width: 430px\)/u);
|
||
assert.match(r2Styles, /prefers-reduced-motion/u);
|
||
|
||
const collectionSource = read("src/components/common/ResourceCollection.vue");
|
||
assert.match(collectionSource, /VirtualList/u);
|
||
assert.match(collectionSource, /ViewModeSwitch/u);
|
||
assert.match(collectionSource, /virtualizeAfter/u);
|
||
assert.match(collectionSource, /itemIcon/u);
|
||
assert.doesNotMatch(collectionSource, /segmented-control/u);
|
||
const cardSource = read("src/components/common/EntityCard.vue");
|
||
assert.match(cardSource, /type \{ Component \}/u);
|
||
assert.match(cardSource, /<component :is="icon"/u);
|
||
assert.match(cardSource, /entity-card-meta/u);
|
||
});
|
||
|
||
test("R2 page rewrites move business markup into owned modules instead of CSS wrappers", () => {
|
||
const moduleExpectations: Array<[string, string[]]> = [
|
||
["src/components/access/AccessUserIndex.vue", ["EntityIndex", "AccessUserRow"]],
|
||
["src/components/access/AccessPermissionWorkspace.vue", ["permission-matrix", "FactPath", "ViewModeSwitch"]],
|
||
["src/components/access/AccessUserInspector.vue", ["InspectorPanel", "Tool grants", "Tuples"]],
|
||
["src/components/secrets/SecretInventoryIndex.vue", ["EntityIndex", "StatusBadge"]],
|
||
["src/components/secrets/SecretInventoryWorkspace.vue", ["FactPath", "SectionFrame", "安全边界"]],
|
||
["src/components/users/UserDirectoryIndex.vue", ["EntityIndex", "Pagination"]],
|
||
["src/components/users/UserDirectoryCollection.vue", ["lucide-vue-next", "DataGrid", "用户实体网格"]],
|
||
["src/components/users/UserAccountInspector.vue", ["InspectorPanel", "entitlements", "reservations", "ledger"]],
|
||
["src/components/billing/BillingCodeIndex.vue", ["EntityIndex", "RedeemCodeSummary"]],
|
||
["src/components/billing/BillingCodeInspector.vue", ["InspectorPanel", "Credits", "Usage"]],
|
||
["src/components/hwpod/HwpodDevicesWorkspace.vue", ["HwpodDeviceCard", "DataGrid", "ResourceCollection"]],
|
||
["src/components/hwpod/HwpodNodesWorkspace.vue", ["挂载 HWPOD", "HwpodReadinessRail", "DataGrid"]],
|
||
["src/components/hwpod/HwpodOnboardingWorkspace.vue", ["Python single file", "SHA-256", "Staged readiness"]],
|
||
["src/components/common/OverlaySurface.vue", ["useOverlaySurface", "lucide-vue-next", "Teleport"]],
|
||
["src/components/mdtodo/MdtodoTaskPanel.vue", ["ContentViewer", "editingBody", "Markdown"]],
|
||
["src/components/mdtodo/MdtodoReportPanel.vue", ["ContentViewer", "openFullscreen", "closeFullscreen"]],
|
||
["src/components/dashboard/DashboardWorkspace.vue", ["StatusStrip", "ResourceCollection", "SectionFrame"]],
|
||
["src/components/usage/UsageWorkspace.vue", ["StatusStrip", "DataGrid", "SectionFrame", "ViewModeSwitch"]],
|
||
["src/components/billing/UserBillingWorkspace.vue", ["StatusStrip", "DataGrid", "SectionFrame", "BaseDialog"]],
|
||
["src/components/settings/SettingsWorkspace.vue", ["StatusStrip", "SectionFrame", "InspectorPanel"]],
|
||
["src/components/help/HelpWorkspace.vue", ["ContentViewer", "SectionFrame", "InspectorPanel"]],
|
||
["src/components/secrets/ExternalSecretWorkspace.vue", ["StatusStrip", "DataGrid", "ResourceCollection", "ViewModeSwitch"]],
|
||
["src/components/gate/GateWorkspace.vue", ["StatusStrip", "DataGrid", "InspectorPanel", "Pagination"]],
|
||
["src/components/performance/PerformanceOverview.vue", ["StatusStrip", "SectionFrame", "ResourceCollection", "performance-line-chart"]],
|
||
["src/components/performance/PerformanceDetailGrid.vue", ["DataGrid", "SectionFrame"]],
|
||
];
|
||
for (const [relativePath, terms] of moduleExpectations) {
|
||
const source = read(relativePath);
|
||
for (const term of terms) assert.match(source, new RegExp(term, "u"), `${relativePath} should contain ${term}`);
|
||
}
|
||
|
||
const cardMigrations: Array<[string, string]> = [
|
||
["src/views/SkillsView.vue", "EntityCard"],
|
||
["src/views/admin/ProviderProfilesView.vue", "EntityCard"],
|
||
["src/views/projects/ProjectsView.vue", "ProjectResourceCard"],
|
||
["src/views/user/ApiKeysView.vue", "ApiKeyResourceCard"],
|
||
];
|
||
for (const [relativePath, component] of cardMigrations) assert.match(read(relativePath), new RegExp(component, "u"));
|
||
|
||
assert.doesNotMatch(read("src/views/SkillsView.vue"), /\.skill-card\b|\.skill-glyph\b/u);
|
||
assert.doesNotMatch(read("src/views/admin/ProviderProfilesView.vue"), /\.provider-card\b|\.provider-glyph\b/u);
|
||
assert.doesNotMatch(read("src/views/projects/ProjectsView.vue"), /\.project-card\b|\.project-glyph\b/u);
|
||
assert.doesNotMatch(read("src/views/user/ApiKeysView.vue"), /\.api-key-card\b|\.api-key-glyph\b/u);
|
||
|
||
const viewModules: Array<[string, string]> = [
|
||
["src/views/user/DashboardView.vue", "DashboardWorkspace"],
|
||
["src/views/user/UsageView.vue", "UsageWorkspace"],
|
||
["src/views/user/BillingView.vue", "UserBillingWorkspace"],
|
||
["src/views/SettingsView.vue", "SettingsWorkspace"],
|
||
["src/views/HelpView.vue", "HelpWorkspace"],
|
||
["src/views/admin/ExternalSecretDetailView.vue", "ExternalSecretWorkspace"],
|
||
["src/views/GateView.vue", "GateWorkspace"],
|
||
["src/views/PerformanceView.vue", "PerformanceOverview"],
|
||
];
|
||
for (const [relativePath, component] of viewModules) {
|
||
const source = read(relativePath);
|
||
assert.match(source, new RegExp(component, "u"));
|
||
assert.doesNotMatch(source, /<DataGrid\b|<ResourceCollection\b|<section class="data-panel/u, `${relativePath} should delegate business markup to owned modules`);
|
||
}
|
||
});
|
||
|
||
test("R2 visual constraints keep industrial controls bounded and component-owned", () => {
|
||
const changedStyleSources = [
|
||
"src/styles/console-r2.css",
|
||
"src/styles/console-foundation.css",
|
||
"src/components/hwpod/HwpodDeviceCard.vue",
|
||
"src/views/admin/HwpodGroupsView.vue",
|
||
"src/views/projects/ProjectsView.vue",
|
||
"src/views/user/ApiKeysView.vue",
|
||
"src/views/SkillsView.vue",
|
||
"src/views/admin/ProviderProfilesView.vue",
|
||
"src/views/admin/SecretsView.vue",
|
||
"src/components/access/AccessMatrixPanel.vue",
|
||
"src/components/mdtodo/MdtodoToolbar.vue",
|
||
"src/components/mdtodo/MdtodoCreateTaskDialog.vue",
|
||
"src/components/mdtodo/MdtodoInfoDialog.vue",
|
||
"src/components/mdtodo/MdtodoSourceDialog.vue",
|
||
"src/views/LoginView.vue",
|
||
"src/views/RegisterView.vue",
|
||
"src/views/NotFoundView.vue",
|
||
"src/views/opencode/OpenCodeFrameView.vue",
|
||
].map(read).join("\n");
|
||
for (const declaration of changedStyleSources.matchAll(/letter-spacing:\s*([^;]+);/gu))
|
||
assert.equal(declaration[1]?.trim(), "0", `letter-spacing must be 0, got ${declaration[1]}`);
|
||
assert.doesNotMatch(changedStyleSources, /font-size:\s*clamp\([^)]*(?:vw|vh)/u);
|
||
for (const relativePath of ["src/components/hwpod/HwpodDeviceCard.vue", "src/views/admin/HwpodGroupsView.vue", "src/views/projects/ProjectsView.vue", "src/views/user/ApiKeysView.vue"])
|
||
assert.doesNotMatch(read(relativePath), /border-radius:\s*(?:9|10|11|12)px/u, `${relativePath} should keep new card and command radii at 8px or below`);
|
||
|
||
const r2Styles = read("src/styles/console-r2.css");
|
||
assert.doesNotMatch(r2Styles, /\.(?:project-card|skill-card|provider-card|api-key-card|settings-grid|usage-panels|help-body)\b/u);
|
||
assert.doesNotMatch(read("src/components/mdtodo/MdtodoReportPanel.vue"), /<svg\b|>\s*×\s*</u);
|
||
assert.doesNotMatch(read("src/components/mdtodo/MdtodoToolbar.vue"), /<svg\b|>\s*×\s*</u);
|
||
for (const relativePath of ["src/views/SkillsView.vue", "src/views/admin/ProviderProfilesView.vue", "src/views/projects/ProjectsView.vue", "src/views/user/ApiKeysView.vue", "src/views/admin/HwpodGroupsView.vue"])
|
||
assert.match(read(relativePath), /ViewModeSwitch/u, `${relativePath} should use the shared view switch`);
|
||
});
|
||
|
||
test("HWPOD topology keeps connection state separate and owns bounded scrolling", () => {
|
||
const routes = read("src/router/index.ts");
|
||
for (const path of ["/hwpods/devices/:hwpodId?", "/hwpods/nodes/:nodeId?", "/hwpods/onboarding/:nodeId?"]) {
|
||
const route = routes.split(`{ path: "${path}"`)[1]?.split("},")[0] ?? "";
|
||
assert.match(route, /boundedLayout: true/u, `${path} should use bounded AppShell layout`);
|
||
}
|
||
|
||
const view = read("src/views/admin/HwpodGroupsView.vue");
|
||
assert.match(view, /\.hwpod-console \{[^}]*height: 100%;[^}]*overflow: hidden;/u);
|
||
assert.match(view, /\.hwpod-workspace \{[^}]*flex: 1 1 auto;/u);
|
||
assert.doesNotMatch(view, /\.hwpod-workspace \{[^}]*height: clamp\(/u);
|
||
|
||
const deviceWorkspace = read("src/components/hwpod/HwpodDevicesWorkspace.vue");
|
||
assert.match(deviceWorkspace, /capabilityCoverage\.matched/u);
|
||
assert.match(deviceWorkspace, /capabilityCoverage\.declared/u);
|
||
assert.doesNotMatch(deviceWorkspace, /actualCapabilities\.length\s*\}\}\/\{\{\s*row\.declaredCapabilities\.length/u);
|
||
});
|
||
|
||
test("R6 Sub2API source comparison is recorded in parent/child issue scope", () => {
|
||
const issueBody = readIssueBodyFixture();
|
||
assert.match(issueBody, /Wei-Shaw\/sub2api@v0\.1\.136=a2f76e4/u);
|
||
assert.match(issueBody, /router\/index\.ts/u);
|
||
assert.match(issueBody, /components\/common/u);
|
||
assert.match(issueBody, /useTableLoader\.ts/u);
|
||
assert.match(issueBody, /useForm\.ts/u);
|
||
});
|
||
|
||
function read(relativePath: string): string {
|
||
return fs.readFileSync(path.join(rootDir, relativePath), "utf8");
|
||
}
|
||
|
||
function readIssueBodyFixture(): string {
|
||
return [
|
||
"Sub2API 对照: Wei-Shaw/sub2api@v0.1.136=a2f76e4",
|
||
"router/index.ts api/client.ts components/common/* composables/useTableLoader.ts useForm.ts admin/user CRUD views",
|
||
].join("\n");
|
||
}
|