feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const webuiRoot = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const settings = read("src/features/settings/SettingsPage.tsx");
|
||||
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
|
||||
@@ -12,6 +14,14 @@ const confirmDialog = read("src/components/ConfirmDialog.tsx");
|
||||
const credentials = read("src/components/CredentialEnvelopeManager.tsx");
|
||||
const iconRail = read("src/layout/IconRail.tsx");
|
||||
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
|
||||
const moduleLoading = read("src/platform/modules.ts");
|
||||
const application = read("src/App.tsx");
|
||||
assert.match(moduleLoading, /importModuleWithRetry\(loader\.load\)/, "local descriptor imports use one bounded retry");
|
||||
assert.match(moduleLoading, /failedLocalModules\.add\(loader\.packageName\)/, "final local import failures retain an explicit unavailable state");
|
||||
assert.match(moduleLoading, /onLoadFailure\?\.\(info\.id\)/, "enabled module import failures are reported to the shell");
|
||||
assert.match(application, /webModuleLoadFailures\.length > 0[\s\S]*optional_module_load_failed/, "an import failure is not silently presented as an uninstalled optional module");
|
||||
assert.match(application, /function WebModuleLoadFailureNotice[\s\S]*useUnsavedChanges\(\)[\s\S]*requestNavigation\(\(\) => window\.location\.reload\(\)\)/, "module recovery reload uses the shared unsaved-draft navigation guard");
|
||||
const wysiwygEditor = read("src/components/WysiwygEditor.tsx");
|
||||
const titlebar = read("src/layout/Titlebar.tsx");
|
||||
const temporalDataMenu = read("src/layout/TemporalDataMenu.tsx");
|
||||
const helpMenu = read("src/layout/HelpMenu.tsx");
|
||||
@@ -27,6 +37,43 @@ const dialogAnatomy = read("src/components/DialogAnatomy.tsx");
|
||||
const adminPageLayout = read("src/components/admin/AdminPageLayout.tsx");
|
||||
const layoutStyles = read("src/styles/layout.css");
|
||||
const authGateStyles = read("src/styles/auth-gate.css");
|
||||
const viteConfig = read("vite.config.ts");
|
||||
const packageManifest = JSON.parse(read("package.json"));
|
||||
|
||||
// Component presence alone cannot detect a collection toolbar in a left pane.
|
||||
await import("./test-workspace-collection-actions.mjs");
|
||||
|
||||
const listSelectionFilter = read("src/components/ListSelectionFilter.tsx");
|
||||
const dataGrid = read("src/components/table/DataGrid.tsx");
|
||||
const multiSelectFilter = read("src/components/MultiSelectFilter.tsx");
|
||||
assert.match(dataGrid, /<ListSelectionFilter\b/, "DataGrid consumes the shared checkbox filter body");
|
||||
assert.match(multiSelectFilter, /<ListSelectionFilter\b/, "standalone list filters consume the same checkbox body");
|
||||
assert.match(listSelectionFilter, /onChange\(null\)/, "Select all explicitly removes the restriction");
|
||||
assert.match(listSelectionFilter, /onChange\(\[\]\)/, "Deselect all explicitly matches nothing");
|
||||
assert.match(read("src/components/Card.tsx"), /bodyLayout\?: "content" \| "table"/, "table cards explicitly own their inset contract");
|
||||
|
||||
// A package in resolve.dedupe is not necessarily prebundled. Check include
|
||||
// specifically: lazy Campaign imports previously triggered dependency churn
|
||||
// after the UI was already open, leaving module-load errors on first visits.
|
||||
const optimization = viteConfig.match(/optimizeDeps:\s*\{([\s\S]*?)\n\s*\},/)?.[1];
|
||||
assert.ok(optimization, "the development dependency optimizer is configured");
|
||||
const optimizedVendors = [...(optimization.match(/include:\s*\[([\s\S]*?)\]/)?.[1] ?? "").matchAll(/"([^"]+)"/g)].map((match) => match[1]);
|
||||
for (const specifier of [
|
||||
"@xyflow/react",
|
||||
"read-excel-file/browser",
|
||||
"read-excel-file/universal",
|
||||
"@tiptap/core",
|
||||
"@tiptap/extension-image",
|
||||
"@tiptap/react",
|
||||
"@tiptap/starter-kit"
|
||||
]) {
|
||||
assert.ok(optimizedVendors.includes(specifier), `${specifier} is prebundled before lazy routes open`);
|
||||
const packageName = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
||||
assert.ok(packageManifest.dependencies[packageName] ?? packageManifest.devDependencies[packageName], `${specifier} is a direct dependency of the development shell`);
|
||||
assert.doesNotThrow(() => require.resolve(specifier), `${specifier} has an installed resolvable entry point`);
|
||||
}
|
||||
assert.match(optimization, /exclude:\s*availableWebModuleSpecifiers\(\)/, "optional module packages stay outside dependency prebundling");
|
||||
assert.ok(!optimizedVendors.some((specifier) => specifier.startsWith("@govoplan/")), "vendor prebundling must not eagerly import optional module packages");
|
||||
|
||||
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
|
||||
assert.match(settings, /archetype=\{editorSection \? "editor" : "workspace"\}/, "settings declare editor intent only for draft-owning sections");
|
||||
@@ -71,6 +118,8 @@ assert.match(pageActionBar, /refreshable: true;\s*reloadAction: PageReloadAction
|
||||
assert.match(pageActionBar, /state: PageEditorState;/, "editors require an explicit persistence lifecycle state");
|
||||
assert.match(pageActionBar, /data-page-dirty-state=/, "editors announce clean, dirty, and saving states");
|
||||
assert.match(pageActionBar, /<ActionSlot name="reload">/, "page action bars keep reload in a named stable slot");
|
||||
assert.match(pageActionBar, /data-page-action-group="trailing"[\s\S]*<ActionSlot name="reload">/, "Reload belongs to the trailing group beside primary actions");
|
||||
assert.doesNotMatch(pageActionBar, /data-page-action-group="leading"(?:(?!<\/ToolbarGroup>)[\s\S])*name="reload"/, "module-independent reload placement must not drift back to the leading group");
|
||||
assert.match(pageActionBar, /data-page-action-separation="destructive"/, "destructive page actions expose a separate semantic group");
|
||||
assert.match(pageActionBar, /<ActionSlot name="discard">[\s\S]*<ActionSlot name="save">/, "editor save follows discard in the trailing group");
|
||||
assert.match(workspaceActionBar, /actionScope=\{scope\}/, "workspace and pane action bars project their semantic scope centrally");
|
||||
@@ -102,5 +151,8 @@ assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
|
||||
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
|
||||
assert.match(moduleLoadBoundary, /<DismissibleAlert tone="danger" compact/, "module failures use the compact shared alert");
|
||||
assert.match(wysiwygEditor, /editor\.setEditable\(!disabled, false\)/, "mounting and permission changes never emit rich-text changes");
|
||||
assert.match(wysiwygEditor, /if \(!isWysiwygDocumentUpdate\(transaction, appendedTransactions\)\) return;/, "rich-text editors ignore non-document update events");
|
||||
assert.match(wysiwygEditor, /if \(nextValue === valueRef\.current\) return;/, "rich-text editors do not report identical content as an edit");
|
||||
|
||||
console.log("Core interface-pattern contracts passed.");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import vm from "node:vm";
|
||||
import ts from "typescript";
|
||||
|
||||
const webuiRoot = resolve(import.meta.dirname, "..");
|
||||
const repositoryRoot = resolve(webuiRoot, "..", "..");
|
||||
@@ -9,6 +11,7 @@ const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8");
|
||||
const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8");
|
||||
const paletteControl = readFileSync(resolve(webuiRoot, "src/components/AppearancePaletteControl.tsx"), "utf8");
|
||||
const overridesEditor = readFileSync(resolve(webuiRoot, "src/components/AppearanceOverridesEditor.tsx"), "utf8");
|
||||
const overridesRuntime = readFileSync(resolve(webuiRoot, "src/components/appearanceOverrides.ts"), "utf8");
|
||||
|
||||
assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required");
|
||||
assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme");
|
||||
@@ -24,11 +27,38 @@ for (const palette of ["default", "civic_blue", "forest", "plum"]) {
|
||||
assert.match(settings, /AppearancePaletteSelect/, "personal settings must use the shared palette control");
|
||||
assert.match(settings, /AppearanceOverridesEditor/, "personal settings must use the shared override editor");
|
||||
assert.match(app, /applyAppearanceOverrides/, "the shell must apply validated overrides centrally");
|
||||
assert.match(overridesEditor, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
|
||||
assert.match(overridesEditor, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
|
||||
assert.match(overridesEditor, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
|
||||
assert.match(app, /import \{ applyAppearanceOverrides \} from "\.\/components\/appearanceOverrides"/, "startup applies themes synchronously without importing settings editor controls");
|
||||
assert.doesNotMatch(overridesRuntime, /import .*from ["']react["']|\.tsx|ColorPickerField|ContentGrid/, "the startup theme runtime must stay independent of editor UI");
|
||||
assert.match(overridesEditor, /from "\.\/appearanceOverrides"/, "editor and runtime share the same validation implementation");
|
||||
assert.match(overridesRuntime, /schema_version:\s*"1"/, "override exchange must use an explicit versioned schema");
|
||||
assert.match(overridesRuntime, /contrastRatio[\s\S]*?<\s*4\.5/, "custom pairs must enforce WCAG AA contrast");
|
||||
assert.match(overridesRuntime, /rgbDistance[\s\S]*?<\s*12/, "custom status colors must enforce differentiation");
|
||||
const runtimeExports = {};
|
||||
vm.runInNewContext(ts.transpileModule(overridesRuntime, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: runtimeExports });
|
||||
const validOverrides = runtimeExports.cloneDefaultAppearanceOverrides();
|
||||
assert.equal(runtimeExports.validateAppearanceOverrides(validOverrides), validOverrides);
|
||||
const colorProperties = new Map();
|
||||
const root = { style: {
|
||||
setProperty: (property, value) => colorProperties.set(property, value),
|
||||
removeProperty: (property) => colorProperties.delete(property)
|
||||
} };
|
||||
runtimeExports.applyAppearanceOverrides(root, validOverrides, "dark");
|
||||
assert.equal(colorProperties.get("--accent"), validOverrides.dark.accent);
|
||||
assert.equal(colorProperties.get("--danger-text"), validOverrides.dark.danger_foreground);
|
||||
runtimeExports.applyAppearanceOverrides(root, validOverrides, "light");
|
||||
assert.equal(colorProperties.get("--accent"), validOverrides.light.accent);
|
||||
const invalidOverrides = runtimeExports.cloneDefaultAppearanceOverrides();
|
||||
invalidOverrides.light.accent_foreground = invalidOverrides.light.accent;
|
||||
assert.throws(() => runtimeExports.validateAppearanceOverrides(invalidOverrides));
|
||||
runtimeExports.applyAppearanceOverrides(root, invalidOverrides, "dark");
|
||||
assert.equal(colorProperties.size, 0, "invalid documents clear previous custom tokens and never partially apply");
|
||||
runtimeExports.applyAppearanceOverrides(root, validOverrides, "light");
|
||||
runtimeExports.applyAppearanceOverrides(root, null, "light");
|
||||
assert.equal(colorProperties.size, 0, "reset restores inherited CSS values synchronously");
|
||||
for (const token of ["accent", "surface", "success", "info", "warning", "danger"]) {
|
||||
assert.match(overridesEditor, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`);
|
||||
assert.match(overridesRuntime, new RegExp(`"--${token}`), `runtime overrides must map ${token} into shared tokens`);
|
||||
}
|
||||
for (const relativePath of [
|
||||
"govoplan-admin/webui/src/features/admin/SystemSettingsPanel.tsx",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { dirname, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveConfig } from "vite";
|
||||
|
||||
const webuiRoot = fileURLToPath(new URL("..", import.meta.url));
|
||||
|
||||
function contains(parent, candidate) {
|
||||
const suffix = relative(parent, candidate);
|
||||
return suffix === "" || (!suffix.startsWith(`..${sep}`) && suffix !== ".." && !suffix.startsWith(sep));
|
||||
}
|
||||
|
||||
// Resolve the real Vite configurations without creating a server, optimizing
|
||||
// dependencies, or changing any existing development/browser-test cache.
|
||||
const app = await resolveConfig({
|
||||
root: webuiRoot,
|
||||
configFile: resolve(webuiRoot, "vite.config.ts")
|
||||
}, "serve");
|
||||
const conformance = await resolveConfig({
|
||||
root: webuiRoot,
|
||||
configFile: resolve(webuiRoot, "vite.conformance.config.ts")
|
||||
}, "serve");
|
||||
|
||||
assert.notEqual(app.cacheDir, conformance.cacheDir,
|
||||
"Browser conformance must never replace the running application's optimized dependencies.");
|
||||
assert.ok(!contains(app.cacheDir, conformance.cacheDir) && !contains(conformance.cacheDir, app.cacheDir),
|
||||
"Neither cache may own the other cache's directory: optimizer cleanup must remain isolated.");
|
||||
assert.equal(dirname(app.cacheDir), dirname(conformance.cacheDir),
|
||||
"Product and conformance use explicit sibling cache directories.");
|
||||
assert.ok(contains(resolve(webuiRoot, "node_modules"), app.cacheDir),
|
||||
"The application cache remains generated local dependency state.");
|
||||
assert.ok(contains(resolve(webuiRoot, "node_modules"), conformance.cacheDir),
|
||||
"The conformance cache remains generated local dependency state.");
|
||||
assert.ok(app.optimizeDeps.include.includes("@xyflow/react"),
|
||||
"Lazy Workflow and Dataflow graph imports are prebundled before their first visit.");
|
||||
assert.ok(app.optimizeDeps.include.includes("read-excel-file/browser"),
|
||||
"Deferred spreadsheet imports remain prebundled.");
|
||||
assert.ok(app.optimizeDeps.include.includes("@tiptap/react"),
|
||||
"Deferred rich-text editors remain prebundled.");
|
||||
|
||||
console.log("Vite product/conformance dependency-cache isolation passed.");
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
// Collection-wide commands belong to the full workspace, never its left pane.
|
||||
// Keep editor/detail commands scoped to their own pane. The real-page browser
|
||||
// scenarios separately verify the shared action engine's physical placement.
|
||||
const pages = [
|
||||
["workflow", "workflow/WorkflowPage.tsx"],
|
||||
["dataflow", "dataflow/DataflowPage.tsx"],
|
||||
["templates", "templates/TemplatesPage.tsx"],
|
||||
["datasources", "datasources/DatasourcesPage.tsx"],
|
||||
["dist-lists", "distributionLists/DistributionListsPage.tsx"],
|
||||
["tasks", "tasks/TasksPage.tsx"],
|
||||
["voting", "voting/VotingPage.tsx"],
|
||||
["scheduling", "scheduling/SchedulingPage.tsx"],
|
||||
["committee", "committee/CommitteePage.tsx"]
|
||||
];
|
||||
|
||||
function attribute(node, name) {
|
||||
const item = node.attributes.properties.find(prop => ts.isJsxAttribute(prop) && prop.name.getText() === name);
|
||||
return item?.initializer && ts.isStringLiteral(item.initializer) ? item.initializer.text : item;
|
||||
}
|
||||
|
||||
let checked = 0;
|
||||
for (const [module, page] of pages) {
|
||||
const path = resolve(import.meta.dirname, `../../../govoplan-${module}/webui/src/features/${page}`);
|
||||
// Core-only checkouts must not acquire dependencies on optional modules.
|
||||
if (!existsSync(path)) continue;
|
||||
checked += 1;
|
||||
const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const bars = [];
|
||||
function visit(node) {
|
||||
if (ts.isJsxSelfClosingElement(node) && node.tagName.getText() === "WorkspaceActionBar" && attribute(node, "variant") === "collection") bars.push(node);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
assert.equal(bars.length, 1, `${module}: one collection-wide action bar`);
|
||||
const bar = bars[0];
|
||||
assert.equal(attribute(bar, "scope"), "workspace", `${module}: collection commands use workspace scope`);
|
||||
assert.ok(attribute(bar, "refreshable") && attribute(bar, "reloadAction") && attribute(bar, "createAction"), `${module}: Reload and creation stay in the same semantic bar`);
|
||||
let parent = bar.parent;
|
||||
while (parent && !ts.isSourceFile(parent)) {
|
||||
assert.ok(!(ts.isJsxAttribute(parent) && parent.name.getText() === "primary"), `${module}: collection commands must not be nested in the left pane`);
|
||||
parent = parent.parent;
|
||||
}
|
||||
let enclosingFrame = bar.parent;
|
||||
while (enclosingFrame && !(ts.isJsxElement(enclosingFrame) && enclosingFrame.openingElement.tagName.getText() === "WorkspaceFrame")) enclosingFrame = enclosingFrame.parent;
|
||||
assert.ok(enclosingFrame, `${module}: the persistent workspace owns its commands`);
|
||||
if (module !== "committee" && module !== "scheduling") {
|
||||
const layout = enclosingFrame.children.find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText() === "WorkspaceLayout");
|
||||
assert.ok(layout && bar.pos < layout.pos, `${module}: commands precede the split workspace`);
|
||||
}
|
||||
}
|
||||
|
||||
// Explorer modules use custom virtualized panes, but not a custom page toolbar.
|
||||
for (const [module, page, variant] of [["files", "files/FilesPage.tsx", "collection"], ["mail", "mail/MailboxPage.tsx", "workspace"]]) {
|
||||
const path = resolve(import.meta.dirname, `../../../govoplan-${module}/webui/src/features/${page}`);
|
||||
if (!existsSync(path)) continue;
|
||||
checked += 1;
|
||||
const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const nodes = [];
|
||||
function visit(node) { nodes.push(node); ts.forEachChild(node, visit); }
|
||||
visit(source);
|
||||
const bars = nodes.filter(node => ts.isJsxSelfClosingElement(node) && node.tagName.getText() === "WorkspaceActionBar" && attribute(node, "scope") === "workspace");
|
||||
assert.equal(bars.length, 1, `${module}: one persistent explorer-wide action bar`);
|
||||
const bar = bars[0];
|
||||
assert.equal(attribute(bar, "variant"), variant, `${module}: explicit semantic action variant`);
|
||||
assert.ok(attribute(bar, "refreshable") && attribute(bar, "reloadAction"), `${module}: current-view Reload is semantic`);
|
||||
assert.equal(Boolean(attribute(bar, "createAction")), module === "files", `${module}: creation only where an owning workflow exists`);
|
||||
const owner = bar.parent;
|
||||
const placements = ts.isVariableDeclaration(owner)
|
||||
? nodes.filter(node => ts.isJsxExpression(node) && node.expression && ts.isIdentifier(node.expression) && node.expression.text === owner.name.getText())
|
||||
: [bar];
|
||||
assert.equal(placements.length, 1, `${module}: workspace bar has one persistent placement`);
|
||||
const placement = placements[0];
|
||||
assert.ok(ts.isJsxElement(placement.parent) && placement.parent.openingElement.tagName.getText() === "WorkspaceFrame", `${module}: commands are direct workspace children, outside panes and selection branches`);
|
||||
const shell = placement.parent.children.find(node => ts.isJsxElement(node) && node.openingElement.tagName.getText() === "div" && /file-manager-shell/.test(node.openingElement.getText()));
|
||||
assert.ok(shell && placement.pos < shell.pos, `${module}: toolbar precedes the explorer panes`);
|
||||
}
|
||||
|
||||
console.log(`Workspace actions: ${checked} present module pages conform.`);
|
||||
Reference in New Issue
Block a user