feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { transformSync } = require("esbuild");
|
||||
const code = transformSync(readFileSync(new URL("../src/api/client.ts", import.meta.url), "utf8"), {
|
||||
loader: "ts", format: "cjs", target: "es2022", define: { "import.meta.env": "{}" }
|
||||
}).code;
|
||||
const settings = { apiBaseUrl: "https://fixture.invalid", accessToken: "", apiKey: "" };
|
||||
const json = (value, headers = {}) => new Response(JSON.stringify(value), {
|
||||
headers: { "Content-Type": "application/json", ...headers }
|
||||
});
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
function harness(respond) {
|
||||
const requests = [];
|
||||
const document = { cookie: "govoplan_csrf=session-one" };
|
||||
const storage = new Map();
|
||||
const events = [];
|
||||
let now = 1000;
|
||||
const context = vm.createContext({
|
||||
Headers, Response, FormData, URL, URLSearchParams, AbortController, CustomEvent, console,
|
||||
window: { dispatchEvent: (event) => events.push(event) },
|
||||
Date: { now: () => now }, document,
|
||||
localStorage: { setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) },
|
||||
sessionStorage: { setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) },
|
||||
fetch: (url, init) => {
|
||||
requests.push({ url, ...init });
|
||||
return respond(requests.at(-1), requests.length);
|
||||
},
|
||||
module: { exports: {} },
|
||||
require: (name) => {
|
||||
assert.equal(name, "../platform/temporal");
|
||||
return { temporalRequestHeaders: () => ({}) };
|
||||
}
|
||||
});
|
||||
context.exports = context.module.exports;
|
||||
vm.runInContext(code, context);
|
||||
return { api: context.module.exports, requests, document, events, tick: (ms = 1000) => { now += ms; } };
|
||||
}
|
||||
|
||||
test("identical simultaneous reads share one request and permitted recent reads reuse it", async () => {
|
||||
const response = deferred();
|
||||
const { api, requests } = harness(() => response.promise);
|
||||
const first = api.apiFetch(settings, "/records");
|
||||
const second = api.apiFetch(settings, "/records");
|
||||
assert.equal(requests.length, 1);
|
||||
response.resolve(json("ready"));
|
||||
assert.deepEqual(await Promise.all([first, second]), ["ready", "ready"]);
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "ready");
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
for (const directive of ["no-store", "private, NO-STORE", "no-cache", "private, max-age=0", "max-age=\"0\""]) {
|
||||
test(`response ${directive} never bypasses a fresh server check`, async () => {
|
||||
const { api, requests } = harness((_request, count) => json(count, { "Cache-Control": directive, ETag: '"same"' }));
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 1);
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 2);
|
||||
if (/no-store/i.test(directive)) assert.equal(requests[1].headers.has("If-None-Match"), false);
|
||||
});
|
||||
}
|
||||
|
||||
test("no-cache responses retain ETags for authorized 304 revalidation, not recent reuse", async () => {
|
||||
const { api, requests } = harness((_request, count) => count === 1
|
||||
? json("ready", { "Cache-Control": "private, no-cache", ETag: '"same"' })
|
||||
: new Response(null, { status: 304, headers: { "Cache-Control": "private, no-cache", ETag: '"same"' } }));
|
||||
await api.apiFetch(settings, "/records");
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "ready");
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "ready");
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(requests[1].headers.get("If-None-Match"), '"same"');
|
||||
});
|
||||
|
||||
test("late pre-mutation reads cannot repopulate either cache", async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests, tick } = harness((_request, count) => count === 1 ? pending.promise : json("fresh"));
|
||||
const oldRead = api.apiFetch(settings, "/records");
|
||||
await api.apiFetch(settings, "/records", { method: "POST", body: "{}" });
|
||||
pending.resolve(json("old", { ETag: '"old"' }));
|
||||
await oldRead;
|
||||
tick();
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "fresh");
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
|
||||
test("reads completed during a mutation are invalidated when that mutation completes", async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests } = harness((request) => request.method === "POST" ? pending.promise : json("read", { ETag: '"during"' }));
|
||||
const write = api.apiFetch(settings, "/records", { method: "POST", body: "{}" });
|
||||
await api.apiFetch(settings, "/records");
|
||||
pending.resolve(json("saved"));
|
||||
await write;
|
||||
await api.apiFetch(settings, "/records");
|
||||
assert.equal(requests.length, 3);
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
|
||||
test("a 304 arriving after invalidation cannot resurrect the old response", async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests, tick } = harness((_request, count) => count === 1
|
||||
? json("old", { ETag: '"old"' }) : count === 2 ? pending.promise : json("fresh"));
|
||||
await api.apiFetch(settings, "/records");
|
||||
tick();
|
||||
const revalidate = api.apiFetch(settings, "/records");
|
||||
await api.apiFetch(settings, "/records", { method: "POST", body: "{}" });
|
||||
pending.resolve(new Response(null, { status: 304 }));
|
||||
await revalidate;
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "fresh");
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
|
||||
test("cookie session changes cannot reuse old response bodies or in-flight requests", async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests, document, tick } = harness((_request, count) => count === 1 ? pending.promise : json("new-session"));
|
||||
const oldRead = api.apiFetch(settings, "/records");
|
||||
document.cookie = "govoplan_csrf=session-two";
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "new-session");
|
||||
pending.resolve(json("old-session", { ETag: '"old"' }));
|
||||
await oldRead;
|
||||
tick();
|
||||
await api.apiFetch(settings, "/records");
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
|
||||
for (const transition of ["clearApiReadCache", "saveApiSettings", "clearAccessToken"]) {
|
||||
test(`${transition} discards response bodies across authentication transitions`, async () => {
|
||||
const { api, requests } = harness((_request, count) => json(count, { ETag: '"old"' }));
|
||||
await api.apiFetch(settings, "/records");
|
||||
api[transition](settings);
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 2);
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
}
|
||||
|
||||
test("401 clears cached data without needing a successful logout", async () => {
|
||||
const { api, requests } = harness((_request, count) => count === 2
|
||||
? new Response("expired", { status: 401 }) : json(count, { ETag: '"old"' }));
|
||||
await api.apiFetch(settings, "/records");
|
||||
await assert.rejects(api.apiFetch(settings, "/session"), { status: 401 });
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 3);
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
|
||||
test("explicit no-cache reads bypass recent reuse", async () => {
|
||||
const { api, requests } = harness((_request, count) => json(count));
|
||||
await api.apiFetch(settings, "/records");
|
||||
assert.equal(await api.apiFetch(settings, "/records", { cache: "no-cache" }), 2);
|
||||
assert.equal(await api.apiFetch(settings, "/records", { headers: { "Cache-Control": "no-cache" } }), 3);
|
||||
assert.equal(requests.length, 3);
|
||||
});
|
||||
|
||||
test("no-store refresh evicts old reusable data for that URL", async () => {
|
||||
const { api, requests } = harness((_request, count) => json(count, { ETag: '"old"' }));
|
||||
await api.apiFetch(settings, "/records");
|
||||
await api.apiFetch(settings, "/records", { cache: "no-store" });
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 3);
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
|
||||
for (const directive of ["no-store", "no-cache", "max-age=0"]) {
|
||||
test(`header-driven ${directive} refresh supersedes old reads and stored data`, async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests } = harness((_request, count) => count === 1 ? pending.promise : json(count));
|
||||
const old = api.apiFetch(settings, "/records");
|
||||
await api.apiFetch(settings, "/records", { headers: { "Cache-Control": directive } });
|
||||
pending.resolve(json("old", { ETag: '"old"' }));
|
||||
await old;
|
||||
assert.equal(await api.apiFetch(settings, "/records"), 3);
|
||||
assert.equal(requests.at(-1).headers.has("If-None-Match"), false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const download of [false, true]) {
|
||||
test(`old ${download ? "download" : "read"} 401 cannot expire a newer session`, async () => {
|
||||
const pending = deferred();
|
||||
const { api, requests, events } = harness((_request, count) => count === 1 ? pending.promise : json("new"));
|
||||
const old = download ? api.apiDownload(settings, "/export", "fixture.json") : api.apiFetch(settings, "/records");
|
||||
api.clearApiReadCache();
|
||||
await api.apiFetch(settings, "/records");
|
||||
pending.resolve(new Response("old session expired", { status: 401 }));
|
||||
await assert.rejects(old, { status: 401 });
|
||||
assert.equal(events.length, 0);
|
||||
assert.equal(await api.apiFetch(settings, "/records"), "new");
|
||||
assert.equal(requests.length, 2);
|
||||
});
|
||||
}
|
||||
|
||||
test("interactive login and logout clear automation credentials instead of shadowing cookie auth", () => {
|
||||
const { api } = harness(() => { throw new Error("No network expected"); });
|
||||
const keyed = { ...settings, apiKey: "fixture-automation-key", accessToken: "fixture-bearer" };
|
||||
for (const next of [{ principal: { auth_method: "session" } }, {}]) {
|
||||
const loggedIn = api.apiSettingsForAuthUpdate(keyed, next, "");
|
||||
assert.equal(loggedIn.apiKey, "");
|
||||
assert.equal(loggedIn.accessToken, "");
|
||||
assert.equal([...api.authHeaders(loggedIn)].length, 0);
|
||||
api.saveApiSettings(loggedIn);
|
||||
}
|
||||
const loggedOut = api.apiSettingsForAuthUpdate(keyed, null);
|
||||
assert.equal(loggedOut.apiKey, "");
|
||||
assert.equal(loggedOut.accessToken, "");
|
||||
const cookieUpdate = api.apiSettingsForAuthUpdate(keyed, { principal: { auth_method: "session" } });
|
||||
assert.equal(cookieUpdate.apiKey, "");
|
||||
assert.equal(api.apiSettingsForAuthUpdate(keyed, { principal: { auth_method: "api_key" } }), keyed);
|
||||
assert.equal(api.apiSettingsForAuthUpdate(keyed, { user: { display_name: "Updated name" } }), keyed);
|
||||
assert.equal(api.apiSettingsForAuthUpdate(settings, { principal: { auth_method: "session" } }), settings,
|
||||
"unchanged settings must keep their identity to avoid profile-fetch effect loops");
|
||||
});
|
||||
@@ -144,6 +144,17 @@ const fixedCoverMarkup = renderToStaticMarkup(
|
||||
getRowKey={(row) => row.id}
|
||||
/>
|
||||
);
|
||||
assertEqual(fixedCoverMarkup.includes('class="data-grid-scroll-region" tabindex="0" role="region"'), true, "horizontal scrolling is keyboard accessible and labelled");
|
||||
|
||||
const resizeMarkup = renderToStaticMarkup(<DataGrid
|
||||
id="resize-control-regression"
|
||||
rows={[] as FilterRow[]}
|
||||
columns={[{ id: "name", header: "Name", resizable: true }, { id: "actions", header: "Actions", columnType: "actions" }]}
|
||||
getRowKey={(row) => row.id}
|
||||
/>);
|
||||
assertEqual(resizeMarkup.includes('role="separator" aria-orientation="vertical"'), true, "resizers expose a focusable vertical separator");
|
||||
assertEqual(resizeMarkup.includes('aria-valuemin="92"'), true, "resizers expose their effective accessible minimum");
|
||||
assertEqual(resizeMarkup.includes('aria-description='), true, "resizers explain keyboard interaction and reset");
|
||||
assertEqual(
|
||||
fixedCoverMarkup.includes("data-grid-buffer-cell"),
|
||||
false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
dataGridActionTrackMinimum,
|
||||
dataGridLayoutSignature,
|
||||
dataGridWidthsForLayout,
|
||||
distributeDataGridResizeAmount,
|
||||
@@ -408,7 +409,14 @@ assertEqual(
|
||||
"free and cover layouts keep independent user overrides"
|
||||
);
|
||||
assertEqual(
|
||||
originalSignature.startsWith("v2::"),
|
||||
originalSignature.startsWith("v3::"),
|
||||
true,
|
||||
"the responsive persistence contract invalidates legacy hard-width snapshots"
|
||||
);
|
||||
|
||||
assertEqual(dataGridActionTrackMinimum({ id: "actions", width: 72 }, 182, 800), 182, "four action slots override a clipped preferred width");
|
||||
assertEqual(dataGridActionTrackMinimum({ id: "actions" }, 182, 280), 140, "narrow action groups leave room to read row data and wrap");
|
||||
assertEqual(dataGridActionTrackMinimum({ id: "actions", minWidth: 160 }, 182, 280), 160, "declared action hard minima remain authoritative");
|
||||
assertEqual(dataGridActionTrackMinimum({ id: "actions" }, Number.NaN, 400), 72, "invalid content measurements cannot poison tracks");
|
||||
assertEqual(dataGridLayoutSignature([{ id: "first", filterable: true }], "container", "cover")
|
||||
=== dataGridLayoutSignature([{ id: "first" }], "container", "cover"), false, "changed header controls invalidate obsolete persisted minima");
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
|
||||
test("development and release manifests retain the patched rich-text dependency floor", () => {
|
||||
for (const filename of ["package.json", "package.release.json"]) {
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${filename}`, import.meta.url), "utf8"));
|
||||
for (const name of ["core", "extension-image", "pm", "react", "starter-kit"]) {
|
||||
assert.equal(manifest.dependencies[`@tiptap/${name}`], "^3.30.4", `${filename}: @tiptap/${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("rich-text attribute merging cannot inherit executable attributes from a JSON prototype key", () => {
|
||||
const untrusted = JSON.parse('{"__proto__":{"onerror":"fixture-canary","src":"fixture-invalid"},"title":"Safe title"}');
|
||||
const attributes = mergeAttributes({ class: "preview" }, untrusted);
|
||||
assert.equal(Object.getPrototypeOf(attributes), Object.prototype);
|
||||
assert.equal(attributes.onerror, undefined);
|
||||
assert.equal(attributes.src, undefined);
|
||||
assert.equal(attributes.title, "Safe title");
|
||||
const enumerable = [];
|
||||
for (const key in attributes) enumerable.push(key);
|
||||
assert.ok(!enumerable.includes("onerror"));
|
||||
assert.ok(!enumerable.includes("src"));
|
||||
assert.equal(Object.prototype.onerror, undefined);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { transformSync } from "esbuild";
|
||||
|
||||
const source = readFileSync(new URL("../../../govoplan-docs/webui/src/features/docs/docsDiscovery.ts", import.meta.url), "utf8");
|
||||
const { code } = transformSync(source, { loader: "ts", format: "esm" });
|
||||
const { documentationTags, documentationTagOptions, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, ancestorOccurrenceIds } = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`);
|
||||
const context = { layers: { configured: { modules: [{ id: "services", name: "Leistungen" }, { id: "forms", name: "Formulare" }] } } };
|
||||
const topic = { id: "topic", source_module_id: "forms", related_modules: ["hidden"], area_module_ids: ["forms", "services"], title: "Application", summary: "", body: "Fill a form", metadata: { tags: ["Entwürfe", "Application", "application", "", 22] } };
|
||||
|
||||
test("area and contributed tags are searchable without leaking unrelated hidden modules", () => {
|
||||
assert.deepEqual(documentationTags(topic, context).map(tag => tag.label), ["Formulare", "Leistungen", "Entwürfe", "Application"]);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "leistungen entwurfe", null), true);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "hidden", null), false);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "fill application", null), true);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "wrong application", null), false);
|
||||
});
|
||||
test("multi-select filter keeps all, none and OR selections distinct", () => {
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "", null), true);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "", []), false);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "", ["area:services", "tag:missing"]), true);
|
||||
assert.equal(matchesDocumentationTopic(topic, context, "", ["tag:missing"]), false);
|
||||
assert.equal(documentationTagOptions([topic, topic], context).length, 4);
|
||||
});
|
||||
test("repeated topics have independent occurrence identity, selection and ancestors", () => {
|
||||
const leaf = { id: "topic:repeat", page: { id: "repeat" }, children: [{ id: "topic:child", page: { id: "child" }, children: [] }] };
|
||||
const tree = qualifyTreeOccurrences([{ id: "tasks", page: { id: "tasks" }, children: [leaf] }, { id: "services", page: { id: "services" }, children: [leaf] }]);
|
||||
const first = tree[0].children[0], second = tree[1].children[0];
|
||||
assert.notEqual(first.id, second.id);
|
||||
assert.notEqual(first.children[0].id, second.children[0].id);
|
||||
assert.equal(selectedTreeOccurrence(tree, "repeat", second.id), second);
|
||||
assert.deepEqual(ancestorOccurrenceIds(tree, second.children[0].id), [tree[1].id, second.id]);
|
||||
assert.equal(selectedTreeOccurrence(tree, "repeat", null), first);
|
||||
assert.equal(selectedTreeOccurrence(tree, "repeat", first.children[0].id), first);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import FloatingStatus from "../src/components/FloatingStatus";
|
||||
import FormSection from "../src/components/FormSection";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import MetricGrid from "../src/components/MetricGrid";
|
||||
import LoadingFrame from "../src/components/LoadingFrame";
|
||||
import PageActionBar from "../src/components/PageActionBar";
|
||||
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
|
||||
import StatePanel from "../src/components/StatePanel";
|
||||
@@ -23,6 +24,29 @@ import ProductAvailabilityState from "../src/components/ProductAvailabilityState
|
||||
import WorkspaceLayout from "../src/components/WorkspaceLayout";
|
||||
import WorkspaceFrame from "../src/components/WorkspaceFrame";
|
||||
import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
|
||||
import Card from "../src/components/Card";
|
||||
import FormField from "../src/components/FormField";
|
||||
import ListSelectionFilter from "../src/components/ListSelectionFilter";
|
||||
|
||||
const tableCard = renderToStaticMarkup(<Card title="Directory" bodyLayout="table"><LoadingFrame loading={false}><div className="data-grid-shell">Rows</div></LoadingFrame></Card>);
|
||||
assert(tableCard.includes('data-card-body-layout="table"'), "table cards explicitly declare zero-inset geometry even with a loading wrapper");
|
||||
const wideField = renderToStaticMarkup(<FormField label="Wide field" className="form-field-wide"><input /></FormField>);
|
||||
assert(wideField.includes('class="form-field form-field-wide"'), "form fields retain layout classes on their labelled root");
|
||||
const filterOptions = [{ value: "a", label: "A" }, { value: "b", label: "B" }];
|
||||
for (const [selection, expectedCount] of [[null, 2], [[], 0], [["a"], 1]] as const) {
|
||||
const filter = renderToStaticMarkup(<ListSelectionFilter options={filterOptions} value={selection === null ? null : [...selection]} onChange={() => undefined} />);
|
||||
assert((filter.match(/checked=""/g) ?? []).length === expectedCount, "list filters distinguish unrestricted, none and explicit subset");
|
||||
}
|
||||
|
||||
const progressFrame = renderToStaticMarkup(<LoadingFrame loading indicator="none" label="Extracting" progress={25} progressLabel="1 of 4 files"><p>Preview</p></LoadingFrame>);
|
||||
assert(progressFrame.includes('aria-busy="true"'), "the shared loading frame marks its content busy");
|
||||
assert(progressFrame.includes('value="25"'), "measured progress is reported through a native progress bar");
|
||||
assert(progressFrame.includes('aria-valuetext="1 of 4 files"'), "progress can describe real processed counts");
|
||||
assert(!progressFrame.includes("loading-envelope"), "operation overlays can omit the envelope animation");
|
||||
const unknownFrame = renderToStaticMarkup(<LoadingFrame loading indicator="none" label="Inspecting" progress={null}><p>Preview</p></LoadingFrame>);
|
||||
assert(unknownFrame.includes("<progress") && !unknownFrame.includes('value="'), "unknown progress is indeterminate, never fabricated");
|
||||
const legacyFrame = renderToStaticMarkup(<LoadingFrame loading><p>Existing consumer</p></LoadingFrame>);
|
||||
assert(legacyFrame.includes("loading-envelope") && !legacyFrame.includes("<progress"), "existing loading-frame consumers retain their presentation");
|
||||
|
||||
// @ts-expect-error Refreshable pages must provide a Reload action.
|
||||
const refreshableWithoutReload = <PageActionBar variant="detail" refreshable />;
|
||||
@@ -71,7 +95,8 @@ assert(editorActionBarMarkup.includes('aria-label="Editor actions"'), "page acti
|
||||
assert(editorActionBarMarkup.includes('data-page-refreshable="true"'), "refreshable pages expose their refresh contract");
|
||||
assert(editorActionBarMarkup.includes('data-page-dirty="true"'), "editors expose their dirty state");
|
||||
assert(editorActionBarMarkup.includes('data-page-dirty-state="dirty"'), "dirty editors announce unsaved changes");
|
||||
assert(editorActionBarMarkup.indexOf('data-page-action-slot="reload"') < editorActionBarMarkup.indexOf('data-page-action-slot="context"'), "reload precedes contextual actions");
|
||||
assert(editorActionBarMarkup.indexOf('data-page-action-slot="context"') < editorActionBarMarkup.indexOf('data-page-action-slot="reload"'), "context stays leading while reload belongs to trailing actions");
|
||||
assert(editorActionBarMarkup.indexOf('data-page-action-group="trailing"') < editorActionBarMarkup.indexOf('data-page-action-slot="reload"'), "reload shares the right-aligned primary action group");
|
||||
assert(editorActionBarMarkup.indexOf('data-page-action-slot="help"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "help precedes editor persistence actions");
|
||||
assert(editorActionBarMarkup.indexOf('data-page-action-slot="destructive"') < editorActionBarMarkup.indexOf('data-page-action-slot="discard"'), "destructive editor actions are separated from persistence actions");
|
||||
assert(editorActionBarMarkup.includes('data-page-action-separation="destructive"'), "destructive actions expose their visual boundary");
|
||||
@@ -103,6 +128,7 @@ const collectionActionBarMarkup = renderToStaticMarkup(
|
||||
</PlatformLanguageProvider>
|
||||
);
|
||||
assert(collectionActionBarMarkup.indexOf('data-page-action-slot="reload"') < collectionActionBarMarkup.indexOf('data-page-action-slot="create"'), "collection creation remains the far-right action");
|
||||
assert(!collectionActionBarMarkup.includes('data-page-action-group="leading"'), "empty context groups do not create phantom rows in narrow toolbars");
|
||||
|
||||
const workspaceEditorActionBarMarkup = renderToStaticMarkup(
|
||||
<PlatformLanguageProvider>
|
||||
|
||||
@@ -19,9 +19,28 @@ import MessageDisplayPanel, { buildSafeMessageHtmlDocument } from "../src/compon
|
||||
import MailServerSettingsPanel, { MailServerFolderLookupResultView, mailImapSettingsPayload, normalizeMailImapFolderMappings, resolveMailServerSettingsActiveSection } from "../src/components/mail/MailServerSettingsPanel";
|
||||
import EmailAddressInput from "../src/components/email/EmailAddressInput";
|
||||
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
|
||||
import { dedupeAddresses } from "../src/utils/emailAddresses";
|
||||
|
||||
function noop() {}
|
||||
|
||||
const orderedAddresses = [
|
||||
{ name: "Zulu", email: " ZULU@example.test " },
|
||||
{ email: "alpha@example.test" },
|
||||
{ name: "Alpha", email: "ALPHA@example.test" },
|
||||
{ name: "Beta", email: "beta@example.test" }
|
||||
];
|
||||
assertDeepEqual(
|
||||
dedupeAddresses(orderedAddresses).map((item) => item.name),
|
||||
["Alpha", "Beta", "Zulu"],
|
||||
"address suggestion consumers retain the alphabetical default"
|
||||
);
|
||||
assertDeepEqual(dedupeAddresses(orderedAddresses, { preserveOrder: true }), [
|
||||
{ name: "Zulu", email: "zulu@example.test" },
|
||||
{ name: "Alpha", email: "alpha@example.test" },
|
||||
{ name: "Beta", email: "beta@example.test" }
|
||||
], "authored recipient order is preserved while duplicates enrich the first occurrence");
|
||||
assertEqual(orderedAddresses[0].email, " ZULU@example.test ", "deduplication does not mutate its input");
|
||||
|
||||
const allMailServerSections = ["smtp", "imap"] as const;
|
||||
assertEqual(
|
||||
resolveMailServerSettingsActiveSection("imap", "smtp", "smtp", allMailServerSections),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
DashboardWidgetsUiCapability,
|
||||
OrganizationFunctionActionContext,
|
||||
OrganizationFunctionActionContribution,
|
||||
PlatformNavItem,
|
||||
PlatformWebModule
|
||||
} from "../src/types";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
visibleRoutesForProjection
|
||||
} from "../src/platform/views";
|
||||
import { groupNavigationItems } from "../src/platform/productAreas";
|
||||
import { inheritedNavigationLayout, materializeNavigationLayout, moveNavigationEntry, navigationEditorOrder } from "../src/components/navigationPreferenceLayout";
|
||||
import {
|
||||
composeProductSurfaces,
|
||||
projectProductNavigation
|
||||
@@ -197,6 +199,24 @@ assert(
|
||||
"flat navigation should retain every authorized destination"
|
||||
);
|
||||
|
||||
const railItems = [
|
||||
{ to: "/files", label: "Files", surfaceId: "files", navigationId: "files", navigationCustomLayout: true, navigationLayoutSource: "tenant", navigationSection: { id: "separator:a", label: "A" } },
|
||||
{ to: "/mail", label: "Mail", surfaceId: "mail", navigationId: "messages", navigationLocked: true, navigationCustomLayout: true, navigationLayoutSource: "tenant", navigationSection: { id: "separator:b", label: "B" } }
|
||||
];
|
||||
assert(groupNavigationItems(railItems, []).map((group) => group.label).join() === "A,B", "explicit separators survive module-default grouping and filtering");
|
||||
const viewNavigation = { contract_version: "1" as const, order: ["messages", "separator:view", "files"], hidden: ["mail"], separators: [{ id: "separator:view", label: "View" }] };
|
||||
const viewGroups = groupNavigationItems(railItems, [], { navigation: viewNavigation });
|
||||
assert(viewGroups[0]?.items[0]?.to === "/mail", "View layout supports navigation aliases and cannot hide locked module");
|
||||
assert(viewGroups[1]?.label === "View" && viewGroups.flatMap((group) => group.items).length === 2, "View separators retain every authorized item once");
|
||||
assert(groupNavigationItems(railItems.map((item) => ({ ...item, navigationLayoutSource: "user" })), [], { navigation: viewNavigation })[0]?.items[0]?.to === "/files", "personal layout takes precedence over View presentation order");
|
||||
assert(groupNavigationItems(railItems.map((item) => ({ ...item, navigationVisibilitySource: "user" })), [], { navigation: { ...viewNavigation, hidden: ["files"] } }).flatMap((group) => group.items).some((item) => item.to === "/files"), "legacy personal visibility-only preferences also take precedence over View navigation hiding");
|
||||
const inheritedRail = inheritedNavigationLayout(railItems, "system", []);
|
||||
assert(inheritedRail.order.join() === "files,messages", "system baseline ignores current tenant grouping");
|
||||
const materialized = materializeNavigationLayout({ contract_version: "1", order: ["mail", "files"], hidden: [] }, { contract_version: "1", order: ["separator:work", "files", "mail"], hidden: [], separators: [{ id: "separator:work", label: "Work" }] });
|
||||
assert(materialized.order.join() === "separator:work,mail,files", "legacy drafts gain inherited separator without losing their item order");
|
||||
assert(moveNavigationEntry(["a", "separator:b", "c"], "c", "a").join() === "c,a,separator:b", "drag reorder treats separators as ordinary ordered entries");
|
||||
assert(navigationEditorOrder(railItems, { contract_version: "1", order: ["files", "messages"], hidden: ["files"], separators: [] }).join() === "messages", "removed modules leave editor list while remaining available for re-add");
|
||||
|
||||
const productSurfaceExplanation = {
|
||||
reason: "authorization" as const,
|
||||
title: "Messages are unavailable",
|
||||
@@ -293,6 +313,25 @@ const mailOnlyNavigation = projectProductNavigation(
|
||||
messageSurfaceModules,
|
||||
{ scopes: ["mail:mailbox:read"] } as AuthInfo
|
||||
);
|
||||
|
||||
const reorderedOwners: PlatformNavItem[] = [
|
||||
{ to: "/postbox", label: "Postbox", order: 0, navigationId: "postbox.navigation.postbox", surfaceId: "postbox.navigation.postbox", anyOf: ["postbox:message:read"], navigationLocked: true, navigationLockSource: "system", navigationCustomLayout: true, navigationSection: { id: "separator:locked", label: "Locked group" } },
|
||||
{ to: "/files", label: "Files", order: 1 },
|
||||
{ to: "/mail", label: "Mail", order: 2, navigationId: "mail.navigation.mail", surfaceId: "mail.navigation.mail", anyOf: ["mail:mailbox:read"] }
|
||||
];
|
||||
const reorderedProductNavigation = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo);
|
||||
const reorderedMessages = reorderedProductNavigation.primaryItems[0];
|
||||
assert(reorderedProductNavigation.primaryItems.map((item) => item.to).join() === "/messages,/files", "composed placement follows earliest effective owner order rather than fixed contributor priority");
|
||||
assert(reorderedMessages.navigationSection?.id === "separator:locked", "composed entry keeps the earliest owner separator");
|
||||
assert(reorderedMessages.navigationAliases?.includes("mail.navigation.mail") && reorderedMessages.navigationAliases?.includes("postbox.navigation.postbox"), "composition retains every consumed owner navigation ID for layout aliases");
|
||||
assert(reorderedMessages.navigationLocked && reorderedMessages.navigationLockSource === "system", "a secondary owner's system lock survives composition with an unlocked primary owner");
|
||||
assert(composeProductSurfaces(messageSurfaceModules)[0].contributors[0].moduleId === "mail", "custom rail placement never changes operational contributor priority");
|
||||
const lockedAfterView = groupNavigationItems(reorderedProductNavigation.primaryItems, [], { navigation: { contract_version: "1", order: [], hidden: ["communication.messages", "mail.navigation.mail", "postbox.navigation.postbox"], separators: [] } });
|
||||
assert(lockedAfterView.flatMap((group) => group.items).some((item) => item.to === "/messages"), "View aliases cannot hide a composed entry locked by any consumed authorized owner");
|
||||
const aliasOrdered = groupNavigationItems(reorderedProductNavigation.primaryItems, [], { navigation: { contract_version: "1", order: ["/files", "separator:view", "mail.navigation.mail"], hidden: [], separators: [{ id: "separator:view", label: "View group" }] } });
|
||||
assert(aliasOrdered[0]?.items[0]?.to === "/files" && aliasOrdered[1]?.items[0]?.to === "/messages" && aliasOrdered[1]?.label === "View group", "View ordering recognizes non-placement owner aliases and assigns the requested separator exactly once");
|
||||
const unauthorizedLockedOwner = projectProductNavigation(reorderedOwners, messageSurfaceModules, { scopes: ["mail:mailbox:read"] } as AuthInfo).primaryItems.find((item) => item.to === "/messages");
|
||||
assert(unauthorizedLockedOwner?.navigationLocked === false && !unauthorizedLockedOwner.navigationAliases?.includes("postbox.navigation.postbox"), "unavailable optional owners contribute neither aliases nor lock metadata");
|
||||
assert(
|
||||
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
|
||||
"composition should remain stable when only one optional contributor is authorized"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { importModuleWithRetry } from "../src/platform/moduleLoading";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function verifyModuleImportRetry() {
|
||||
let attempts = 0;
|
||||
let pauses = 0;
|
||||
const module = { id: "files", uiCapabilities: { "files.fileExplorer": {} } };
|
||||
const imported = await importModuleWithRetry(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new TypeError("Synthetic transient import failure");
|
||||
return module;
|
||||
}, async () => { pauses += 1; });
|
||||
assert(imported === module, "a second successful import preserves the real module descriptor");
|
||||
assert(attempts === 2 && pauses === 1, "a transient failure gets exactly one bounded retry");
|
||||
|
||||
attempts = 0;
|
||||
pauses = 0;
|
||||
const terminal = new TypeError("Synthetic final import failure");
|
||||
let observed: unknown;
|
||||
try {
|
||||
await importModuleWithRetry(async () => { attempts += 1; throw terminal; }, async () => { pauses += 1; });
|
||||
} catch (error) { observed = error; }
|
||||
assert(observed === terminal, "a final failure must be reported, never replaced by an empty or fake module");
|
||||
assert(attempts === 2 && pauses === 1, "permanent failure cannot start an unbounded retry loop");
|
||||
|
||||
attempts = 0;
|
||||
pauses = 0;
|
||||
await importModuleWithRetry(async () => { attempts += 1; return module; }, async () => { pauses += 1; });
|
||||
assert(attempts === 1 && pauses === 0, "healthy imports do not pause or repeat");
|
||||
console.log("Module import retry tests passed.");
|
||||
}
|
||||
|
||||
void verifyModuleImportRetry().catch((error) => { throw error; });
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "../src/components/wysiwygEditorUrls";
|
||||
import { isWysiwygDocumentUpdate } from "../src/components/wysiwygEditorUpdates";
|
||||
|
||||
function assertEqual(actual: unknown, expected: unknown, message: string): void {
|
||||
if (actual !== expected) {
|
||||
@@ -20,3 +21,8 @@ assertEqual(normalizeWysiwygImageUrl("cid:campaign-banner"), "cid:campaign-banne
|
||||
assertEqual(normalizeWysiwygImageUrl("data:image/png;base64,iVBORw0KGgo="), "data:image/png;base64,iVBORw0KGgo=", "raster data images are accepted");
|
||||
assertEqual(normalizeWysiwygImageUrl("data:image/svg+xml;base64,PHN2Zz4="), null, "active SVG data images are rejected");
|
||||
assertEqual(normalizeWysiwygImageUrl("javascript:alert(1)"), null, "script image sources are rejected");
|
||||
|
||||
assertEqual(isWysiwygDocumentUpdate({ docChanged: false }), false, "mount/editability updates do not dirty loaded content");
|
||||
assertEqual(isWysiwygDocumentUpdate({ docChanged: false }, [{ docChanged: false }]), false, "selection-only updates do not dirty loaded content");
|
||||
assertEqual(isWysiwygDocumentUpdate({ docChanged: true }), true, "typing and formatting publish document edits");
|
||||
assertEqual(isWysiwygDocumentUpdate({ docChanged: false }, [{ docChanged: true }]), true, "appended document edits are not lost");
|
||||
|
||||
Reference in New Issue
Block a user