mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
* fix(i18n): use sentence case for English menu labels and headings
Menu items, buttons, dialog titles and headings in en.json mixed Title
Case and sentence case. Switch the Title Case labels to sentence case,
keeping proper nouns (products, services, standards, places), acronyms,
and names of other UI surfaces ("the Layers panel") capitalized.
Processing tool names are left alone: they come from the tool registries
through the generated processing.toolMeta keys, and the matching
toolbar.*Tool menus mirror them. Projection and CRS preset names are
proper names and stay as they are.
Also:
- update the English fallbacks next to catalog keys, three hard-coded
Add Data panel titles, and the plugin reference menu paths
- regenerate docs/user-guide/plugins.md
- update e2e selectors and unit-test expectations
- lower the same strings where another locale copied the English verbatim
(German is left alone: it capitalizes nouns)
- document the casing and ellipsis conventions in docs/i18n.md
Fixes #3231
* fix(i18n): match the Layers header collapse label to the rail's
The left rail reads "Collapse Layers" (Collapse {{title}} with the panel
name), so the Layers header button now says the same instead of
"Collapse layers".
* Address Claude review feedback
- Match the vantor.openPanel aria-label and title fallbacks in the Vantor
panel to the sentence-case catalog value ("Open Vantor STAC explorer").
* Address Claude review feedback
- Update English strings that quote a renamed label or menu path to the
new sentence-case names ("Download offline area", "Add comment",
Settings → Environment variables, Project → Export → Export layers,
Add data → LiDAR layer, and others), including the matching code
fallbacks and hard-coded hints.
- Keep "Open in Processing…": Processing is the toolbar menu's name, so it
keeps its capital under the new style rule.
* Address Claude review feedback
- Match the share.role and share.expiry fallbacks in the Share dialog to
en.json ("Access role", "Link expiry"). A sweep of every key/fallback
pair found no other case-only mismatch.
447 lines
18 KiB
TypeScript
447 lines
18 KiB
TypeScript
import { expect, test, type Page } from "./test";
|
|
import { strToU8, zipSync } from "fflate";
|
|
import { createEmptyProject, DEFAULT_LAYER_STYLE } from "@geolibre/core";
|
|
|
|
// The fixture exposes activation in the DOM so end-to-end cases can observe
|
|
// real PluginManager lifecycle calls.
|
|
const PLUGIN_ID = "e2e-sample-plugin";
|
|
const PLUGIN_NAME = "E2E Sample Plugin";
|
|
const PLUGIN_VERSION = "1.0.0";
|
|
|
|
const MANIFEST = {
|
|
id: PLUGIN_ID,
|
|
name: PLUGIN_NAME,
|
|
version: PLUGIN_VERSION,
|
|
entry: "plugin.js",
|
|
};
|
|
|
|
const ENTRY_SOURCE = `const plugin = {
|
|
id: ${JSON.stringify(PLUGIN_ID)},
|
|
name: ${JSON.stringify(PLUGIN_NAME)},
|
|
version: ${JSON.stringify(PLUGIN_VERSION)},
|
|
activate() {
|
|
const output = document.createElement("output");
|
|
output.id = "e2e-sample-plugin-active";
|
|
document.body.append(output);
|
|
},
|
|
deactivate() {
|
|
document.getElementById("e2e-sample-plugin-active")?.remove();
|
|
},
|
|
};
|
|
export default plugin;
|
|
`;
|
|
|
|
// Wrap the plugin in a top-level folder (as produced by zipping a plugin
|
|
// directory) so this exercises the wrapping-folder path, not just a root
|
|
// plugin.json.
|
|
function buildPluginZip(): Buffer {
|
|
const archive = zipSync({
|
|
"e2e-sample-plugin/plugin.json": strToU8(JSON.stringify(MANIFEST)),
|
|
"e2e-sample-plugin/plugin.js": strToU8(ENTRY_SOURCE),
|
|
});
|
|
return Buffer.from(archive);
|
|
}
|
|
|
|
/**
|
|
* Opens the toolbar's Plugins menu and its Installed submenu, where external
|
|
* plugins have been listed since #2851. Pass `installed: true` when a plugin is
|
|
* expected: external plugins load asynchronously, so this waits for the
|
|
* submenu. Otherwise it opens the submenu only if present, because it exists
|
|
* only while an external plugin is loaded, and an absent plugin still checks out.
|
|
*/
|
|
async function openPluginsMenu(
|
|
page: Page,
|
|
{ label = "Plugins", installedLabel = "Installed", installed = false } = {},
|
|
) {
|
|
await page.getByRole("button", { name: label, exact: true }).click();
|
|
await expect(page.getByRole("menu").first()).toBeVisible();
|
|
const submenu = page.getByRole("menuitem", { name: new RegExp(`^${installedLabel}( ✓)?$`) });
|
|
if (installed) await expect(submenu).toBeVisible();
|
|
if ((await submenu.count()) > 0) await submenu.hover();
|
|
}
|
|
|
|
/** Open Manage Plugins from Settings and switch to its Settings tab. */
|
|
async function openManagePluginsSettings(page: Page, locale: "en" | "zh" = "en") {
|
|
const labels =
|
|
locale === "zh"
|
|
? { settings: "设置", managePlugins: "管理插件" }
|
|
: { settings: "Settings", managePlugins: "Manage plugins" };
|
|
await page.getByRole("button", { name: labels.settings, exact: true }).click();
|
|
await page.getByRole("menuitem", { name: labels.managePlugins }).click();
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog.getByText(labels.managePlugins)).toBeVisible();
|
|
await dialog.getByRole("button", { name: labels.settings, exact: true }).click();
|
|
return dialog;
|
|
}
|
|
|
|
test("installs a plugin from an uploaded zip, persists it across reload, and uninstalls it", async ({
|
|
page,
|
|
}) => {
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
// The Settings dropdown trigger is part of the toolbar; wait for it before
|
|
// driving the menu.
|
|
await expect(page.getByRole("button", { name: "Settings", exact: true })).toBeVisible();
|
|
|
|
// 1. Install from file: the web path reads the uploaded bytes, unpacks and
|
|
// registers the plugin client-side, and persists it in IndexedDB.
|
|
let dialog = await openManagePluginsSettings(page);
|
|
const [fileChooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
dialog.getByRole("button", { name: /Choose \.zip/ }).click(),
|
|
]);
|
|
await fileChooser.setFiles({
|
|
name: `${PLUGIN_ID}.zip`,
|
|
mimeType: "application/zip",
|
|
buffer: buildPluginZip(),
|
|
});
|
|
|
|
// Success notice + the plugin appears in the "installed from file" list.
|
|
await expect(dialog.getByText(`Installed plugin "${PLUGIN_ID}".`)).toBeVisible();
|
|
await expect(dialog.getByText(PLUGIN_NAME)).toBeVisible();
|
|
await expect(dialog.getByRole("button", { name: `Uninstall ${PLUGIN_NAME}` })).toBeVisible();
|
|
|
|
// Close the dialog and confirm the plugin registered (it shows in the
|
|
// toolbar's Plugins menu, which lists registered plugins by name).
|
|
await page.keyboard.press("Escape");
|
|
await openPluginsMenu(page, { installed: true });
|
|
await expect(page.getByRole("menu").getByText(PLUGIN_NAME)).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
|
|
// 2. Persistence: reload the app. The startup loader replays the bundle from
|
|
// IndexedDB, so the plugin is registered again and still listed.
|
|
await page.reload();
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
await openPluginsMenu(page, { installed: true });
|
|
await expect(page.getByRole("menu").getByText(PLUGIN_NAME)).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
|
|
dialog = await openManagePluginsSettings(page);
|
|
await expect(dialog.getByText(PLUGIN_NAME)).toBeVisible();
|
|
|
|
// 3. Uninstall: removes it from IndexedDB and unregisters it.
|
|
await dialog.getByRole("button", { name: `Uninstall ${PLUGIN_NAME}` }).click();
|
|
await expect(dialog.getByRole("button", { name: `Uninstall ${PLUGIN_NAME}` })).toHaveCount(0);
|
|
|
|
// It no longer appears in the Plugins menu either.
|
|
await page.keyboard.press("Escape");
|
|
await openPluginsMenu(page);
|
|
await expect(page.getByRole("menu").getByText(PLUGIN_NAME)).toHaveCount(0);
|
|
});
|
|
|
|
test("deployment defaultActive applies to fresh projects but respects explicit deactivation", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/deployment.json", (route) =>
|
|
route.fulfill({
|
|
json: { version: 1, plugins: { defaultActive: [PLUGIN_ID] } },
|
|
}),
|
|
);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
const dialog = await openManagePluginsSettings(page);
|
|
const [chooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
dialog.getByRole("button", { name: /Choose \.zip/ }).click(),
|
|
]);
|
|
await chooser.setFiles({
|
|
name: `${PLUGIN_ID}.zip`,
|
|
mimeType: "application/zip",
|
|
buffer: buildPluginZip(),
|
|
});
|
|
await expect(dialog.getByText(`Installed plugin "${PLUGIN_ID}".`)).toBeVisible();
|
|
await expect(page.locator("#e2e-sample-plugin-active")).toBeAttached();
|
|
await page.keyboard.press("Escape");
|
|
|
|
await openPluginsMenu(page, { installed: true });
|
|
await page.getByRole("menuitem", { name: new RegExp(`^${PLUGIN_NAME}`) }).click();
|
|
await expect(page.locator("#e2e-sample-plugin-active")).toHaveCount(0);
|
|
|
|
const root = page.locator("html");
|
|
const previousGeneration = await root.getAttribute("data-geolibre-project-generation");
|
|
await page.getByRole("button", { name: "Project", exact: true }).click();
|
|
await page.getByRole("menuitem", { name: "New…", exact: true }).click();
|
|
const newProjectDialog = page.getByRole("dialog", { name: "New project" });
|
|
const discardButton = page.getByRole("button", { name: "Do not save", exact: true });
|
|
// The unsaved-changes prompt (when it shows) opens a beat after the menu
|
|
// closes, so wait for it or the dialog instead of probing once.
|
|
await expect(discardButton.or(newProjectDialog)).toBeVisible();
|
|
if (await discardButton.isVisible()) await discardButton.click();
|
|
await expect(newProjectDialog).toBeVisible();
|
|
await newProjectDialog.getByRole("button", { name: "Create", exact: true }).click();
|
|
await expect
|
|
.poll(async () => root.getAttribute("data-geolibre-project-generation"))
|
|
.not.toBe(previousGeneration);
|
|
await expect
|
|
.poll(async () => {
|
|
const generation = await root.getAttribute("data-geolibre-project-generation");
|
|
const ready = await root.getAttribute("data-geolibre-project-ready-generation");
|
|
return generation !== null && generation === ready;
|
|
})
|
|
.toBe(true);
|
|
await expect(page.locator("#e2e-sample-plugin-active")).toBeAttached();
|
|
});
|
|
|
|
test("blocked archive installs report denial without registering or persisting the plugin", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/deployment.json", (route) =>
|
|
route.fulfill({
|
|
json: { version: 1, plugins: { blocked: [PLUGIN_ID] } },
|
|
}),
|
|
);
|
|
await page.goto("/?locale=zh");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
const dialog = await openManagePluginsSettings(page, "zh");
|
|
const [chooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
dialog.getByRole("button", { name: /选择 \.zip/ }).click(),
|
|
]);
|
|
await chooser.setFiles({
|
|
name: `${PLUGIN_ID}.zip`,
|
|
mimeType: "application/zip",
|
|
buffer: buildPluginZip(),
|
|
});
|
|
await expect(dialog.getByText(`插件“${PLUGIN_ID}”已被部署策略阻止。`)).toBeVisible();
|
|
await expect(dialog.getByRole("button", { name: `卸载 ${PLUGIN_NAME}` })).toHaveCount(0);
|
|
await page.reload();
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
await openPluginsMenu(page, { label: "插件", installedLabel: "已安装" });
|
|
await expect(page.getByRole("menu").getByText(PLUGIN_NAME)).toHaveCount(0);
|
|
});
|
|
|
|
test("sideload=false hides installation controls and ignores project manifest URLs without prompting", async ({
|
|
page,
|
|
}) => {
|
|
await page.route("**/deployment.json", (route) =>
|
|
route.fulfill({ json: { version: 1, plugins: { sideload: false } } }),
|
|
);
|
|
const manifestUrl = "https://example.com/e2e-policy-plugin/plugin.json";
|
|
const pluginRequests: string[] = [];
|
|
await page.route("https://example.com/e2e-policy-plugin/**", (route) => {
|
|
pluginRequests.push(route.request().url());
|
|
return route.abort();
|
|
});
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
|
|
const dialog = await openManagePluginsSettings(page);
|
|
await expect(dialog.getByRole("button", { name: /Choose \.zip/ })).toHaveCount(0);
|
|
await expect(dialog.getByRole("textbox", { name: "Plugin manifest URL" })).toHaveCount(0);
|
|
await expect(dialog.getByRole("textbox", { name: "Plugin directory" })).toHaveCount(0);
|
|
await expect(dialog.getByRole("button", { name: "Browse plugin directory" })).toHaveCount(0);
|
|
await page.keyboard.press("Escape");
|
|
|
|
const project = createEmptyProject();
|
|
project.plugins = {
|
|
manifestUrls: [manifestUrl],
|
|
activePluginIds: [PLUGIN_ID],
|
|
mapControlPositions: {},
|
|
settings: {},
|
|
};
|
|
project.layers = [
|
|
{
|
|
id: "policy-project-layer",
|
|
name: "Policy project loaded",
|
|
type: "geojson",
|
|
source: { type: "geojson" },
|
|
visible: true,
|
|
opacity: 1,
|
|
style: DEFAULT_LAYER_STYLE,
|
|
metadata: {},
|
|
geojson: { type: "FeatureCollection", features: [] },
|
|
},
|
|
];
|
|
const root = page.locator("html");
|
|
const previousGeneration = await root.getAttribute("data-geolibre-project-generation");
|
|
await page.evaluate((text) => {
|
|
const dataTransfer = new DataTransfer();
|
|
dataTransfer.items.add(new File([text], "policy.geolibre.json", { type: "application/json" }));
|
|
const target = document.querySelector('[data-testid="desktop-shell"]')!;
|
|
for (const type of ["dragenter", "dragover", "drop"]) {
|
|
target.dispatchEvent(
|
|
new DragEvent(type, {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
dataTransfer,
|
|
}),
|
|
);
|
|
}
|
|
}, JSON.stringify(project));
|
|
await expect(
|
|
page.locator('[data-testid="layer-row"][data-layer-name="Policy project loaded"]'),
|
|
).toBeVisible();
|
|
await expect
|
|
.poll(async () => root.getAttribute("data-geolibre-project-generation"))
|
|
.not.toBe(previousGeneration);
|
|
await expect
|
|
.poll(async () => {
|
|
const generation = await root.getAttribute("data-geolibre-project-generation");
|
|
const ready = await root.getAttribute("data-geolibre-project-ready-generation");
|
|
return generation !== null && generation === ready;
|
|
})
|
|
.toBe(true);
|
|
await expect(page.getByRole("dialog", { name: "Load plugins from this project?" })).toHaveCount(
|
|
0,
|
|
);
|
|
await openPluginsMenu(page);
|
|
await expect(page.getByRole("menu").getByText(PLUGIN_NAME)).toHaveCount(0);
|
|
expect(pluginRequests).toEqual([]);
|
|
});
|
|
|
|
test("deployment plugin rules filter registry installation entries", async ({ page }) => {
|
|
await page.route("**/deployment.json", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
version: 1,
|
|
plugins: {
|
|
registryUrl: "https://example.com/policy-registry.json",
|
|
allowed: ["allowed-plugin"],
|
|
blocked: ["blocked-plugin"],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await page.route("https://example.com/policy-registry.json", (route) =>
|
|
route.fulfill({
|
|
json: ["allowed-plugin", "blocked-plugin", "unlisted-plugin"].map((id) => ({
|
|
id,
|
|
name: id,
|
|
version: "2.0.0",
|
|
manifestUrl: `https://example.com/${id}/plugin.json`,
|
|
})),
|
|
}),
|
|
);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
const dialog = await openManagePluginsSettings(page);
|
|
await dialog.getByRole("button", { name: /^All / }).click();
|
|
await expect(dialog.getByRole("button", { name: "Install allowed-plugin" })).toBeVisible();
|
|
await expect(dialog.getByText("blocked-plugin", { exact: true })).toHaveCount(0);
|
|
await expect(dialog.getByText("unlisted-plugin", { exact: true })).toHaveCount(0);
|
|
});
|
|
|
|
for (const registryResult of ["delisted", "unavailable", "blocked-id"] as const) {
|
|
test(`registry-only policy unloads an active plugin when its registry becomes ${registryResult}`, async ({
|
|
page,
|
|
}) => {
|
|
const registryUrl = "https://example.com/live-registry.json";
|
|
const manifestUrl = "https://example.com/live-plugin/plugin.json";
|
|
let approved = true;
|
|
const pluginRequests: string[] = [];
|
|
await page.route("**/deployment.json", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
version: 1,
|
|
plugins: { sideload: false, registryUrl, blocked: ["blocked-registry-plugin"] },
|
|
},
|
|
}),
|
|
);
|
|
await page.route(registryUrl, (route) => {
|
|
if (!approved && registryResult === "unavailable") {
|
|
return route.fulfill({ status: 503, body: "Registry unavailable" });
|
|
}
|
|
return route.fulfill({
|
|
json: approved
|
|
? [{ ...MANIFEST, manifestUrl }]
|
|
: registryResult === "blocked-id"
|
|
? [{ ...MANIFEST, id: "blocked-registry-plugin", manifestUrl }]
|
|
: [],
|
|
});
|
|
});
|
|
await page.route("https://example.com/live-plugin/**", (route) => {
|
|
pluginRequests.push(route.request().url());
|
|
if (route.request().url() === manifestUrl) return route.fulfill({ json: MANIFEST });
|
|
return route.fulfill({
|
|
contentType: "text/javascript",
|
|
body: ENTRY_SOURCE.replaceAll("e2e-sample-plugin-active", "registry-plugin-active"),
|
|
});
|
|
});
|
|
await page.addInitScript((url) => {
|
|
localStorage.setItem(
|
|
"geolibre.desktopSettings",
|
|
JSON.stringify({
|
|
uiProfile: { onboarded: true },
|
|
pluginManifestUrls: [url],
|
|
}),
|
|
);
|
|
}, manifestUrl);
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
await openPluginsMenu(page, { installed: true });
|
|
await page.getByRole("menuitem", { name: new RegExp(`^${PLUGIN_NAME}`) }).click();
|
|
await expect(page.locator("#registry-plugin-active")).toBeAttached();
|
|
const initialPluginRequests = [...pluginRequests];
|
|
|
|
approved = false;
|
|
// An unrelated settings change must reclassify installed URLs before
|
|
// reusing the plugin scan cache; German keeps the Plugins menu label.
|
|
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
|
await page.getByRole("menuitem", { name: "Language", exact: true }).hover();
|
|
await page.getByRole("menuitemradio", { name: "Deutsch (German)", exact: true }).click();
|
|
await expect(page.locator("#registry-plugin-active")).toHaveCount(0);
|
|
await openPluginsMenu(page, { installedLabel: "Installiert" });
|
|
await expect(page.getByRole("menuitem", { name: PLUGIN_NAME, exact: true })).toHaveCount(0);
|
|
expect(pluginRequests).toEqual(initialPluginRequests);
|
|
});
|
|
}
|
|
|
|
test("external plugins reproject coordinates with the host's shared proj4", async ({ page }) => {
|
|
const manifest = {
|
|
id: "e2e-proj4-plugin",
|
|
name: "E2E Proj4 Plugin",
|
|
version: "1.0.0",
|
|
entry: "plugin.js",
|
|
};
|
|
const entry = `export default {
|
|
id: "e2e-proj4-plugin",
|
|
name: "E2E Proj4 Plugin",
|
|
version: "1.0.0",
|
|
async activate(app) {
|
|
const module = await app.getProj4();
|
|
const proj4 = module.default;
|
|
proj4.defs("e2e-proj4-plugin:utm32n", "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs");
|
|
const wgs84 = proj4("e2e-proj4-plugin:utm32n", "EPSG:4326", [500000, 0]);
|
|
const projected = proj4("EPSG:4326", "e2e-proj4-plugin:utm32n", wgs84);
|
|
const output = document.createElement("output");
|
|
output.id = "e2e-proj4-result";
|
|
output.textContent = JSON.stringify({ wgs84, projected });
|
|
document.body.append(output);
|
|
},
|
|
deactivate() {
|
|
document.getElementById("e2e-proj4-result")?.remove();
|
|
},
|
|
};`;
|
|
const archive = Buffer.from(
|
|
zipSync({
|
|
"e2e-proj4-plugin/plugin.json": strToU8(JSON.stringify(manifest)),
|
|
"e2e-proj4-plugin/plugin.js": strToU8(entry),
|
|
}),
|
|
);
|
|
|
|
await page.goto("/");
|
|
await expect(page.getByTestId("map-canvas")).toBeVisible();
|
|
const dialog = await openManagePluginsSettings(page);
|
|
const [fileChooser] = await Promise.all([
|
|
page.waitForEvent("filechooser"),
|
|
dialog.getByRole("button", { name: /Choose \.zip/ }).click(),
|
|
]);
|
|
await fileChooser.setFiles({
|
|
name: `${manifest.id}.zip`,
|
|
mimeType: "application/zip",
|
|
buffer: archive,
|
|
});
|
|
await expect(dialog.getByText(`Installed plugin "${manifest.id}".`)).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await openPluginsMenu(page, { installed: true });
|
|
await page.getByRole("menu").getByText(manifest.name, { exact: true }).click();
|
|
|
|
const output = page.locator("#e2e-proj4-result");
|
|
await expect(output).toBeAttached();
|
|
const result = JSON.parse((await output.textContent())!);
|
|
expect(Math.abs(result.wgs84[0] - 9)).toBeLessThan(1e-8);
|
|
expect(Math.abs(result.wgs84[1])).toBeLessThan(1e-8);
|
|
expect(Math.abs(result.projected[0] - 500000)).toBeLessThan(1e-4);
|
|
expect(Math.abs(result.projected[1])).toBeLessThan(1e-4);
|
|
});
|