penpot/frontend/playwright/ui/pages/ShortcutsPage.js
Andrey Antukh b660ea9d53 Route the app by query string with screen key
Move SPA routing out of the URL fragment into the normal query
string. The screen travels in a reserved `screen` key holding
the route name (`?screen=workspace&team-id=…`); every other
param keeps its name. `rt/nav` and `rt/resolve` keep their
signatures.

This deletes the fragment-mirroring URL surgery, simplifies
link-preview (the server sees everything) and nginx (single
path, no SPA fallback rules needed), and migrates OIDC
redirects, email links, e2e helpers and plugin test utils to
the new format.

Legacy `#/…` URLs translate client-side for one Penpot version
(`legacy-routes`, marked TODO(next-version)); non-SPA paths
are untouched.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-15 17:10:54 +02:00

338 lines
9.1 KiB
JavaScript

import { expect } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { BaseWebSocketPage } from "./BaseWebSocketPage";
function decodeKeyword(value) {
if (typeof value === "string" && value.startsWith("~:")) {
return value.slice(2);
}
if (typeof value === "string" && value.startsWith("~$")) {
return value.slice(2);
}
return value;
}
function decodeTransit(data) {
if (Array.isArray(data)) {
if (data[0] === "^ ") {
const result = {};
for (let i = 1; i < data.length; i += 2) {
const key = decodeTransit(data[i]);
const value = decodeTransit(data[i + 1]);
result[key] = value;
}
return result;
}
return data.map(decodeTransit);
}
if (data !== null && typeof data === "object") {
const result = {};
for (const [key, value] of Object.entries(data)) {
result[decodeKeyword(key)] = decodeTransit(value);
}
return result;
}
return decodeKeyword(data);
}
function encodeKeyword(key) {
return `~:${key}`;
}
function encodeTransit(data) {
if (data === null || typeof data === "boolean" || typeof data === "number") {
return data;
}
if (typeof data === "string") {
return data;
}
if (Array.isArray(data)) {
return data.map(encodeTransit);
}
const result = {};
for (const [key, value] of Object.entries(data)) {
result[encodeKeyword(key)] = encodeTransit(value);
}
return result;
}
function decodeRequestBody(body) {
try {
return decodeTransit(JSON.parse(body));
} catch {
return null;
}
}
export class ShortcutsPage extends BaseWebSocketPage {
static async init(page) {
await super.init(page);
const profileText = await readFile(
"playwright/data/logged-in-user/get-profile-logged-in.json",
"utf-8",
);
const baseProfile = decodeTransit(JSON.parse(profileText));
let customShortcuts = null;
await page.route("**/api/main/methods/get-profile", (route) => {
const profile = JSON.parse(JSON.stringify(baseProfile));
if (customShortcuts) {
profile.props["custom-shortcuts"] = customShortcuts;
}
route.fulfill({
status: 200,
contentType: "application/transit+json",
body: JSON.stringify(encodeTransit(profile)),
});
});
await page.route(
"**/api/main/methods/update-profile-props",
async (route, request) => {
const decoded = decodeRequestBody(request.postData() ?? "{}");
if (decoded?.props && "custom-shortcuts" in decoded.props) {
customShortcuts = decoded.props["custom-shortcuts"];
}
route.fulfill({
status: 200,
contentType: "application/transit+json",
body: "{}",
});
},
);
await super.mockRPC(
page,
"get-teams",
"logged-in-user/get-teams-default.json",
);
}
static async initWithShortcuts(page) {
await super.init(page);
await super.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-logged-in.json",
"get-teams": "logged-in-user/get-teams-default.json",
"update-profile-props":
"logged-in-user/update-profile-with-shortcuts.json",
});
}
static async initWithNoShortcuts(page) {
await super.init(page);
await super.mockRPCs(page, {
"get-profile": "logged-in-user/get-profile-no-shortcuts.json",
"get-teams": "logged-in-user/get-teams-default.json",
"update-profile-props":
"logged-in-user/update-profile-with-shortcuts.json",
});
}
constructor(page) {
super(page);
this.shortcutsSection = page.getByRole("region", { name: /shortcuts/i });
this.searchInput = page.getByRole("textbox", { name: /shortcuts/i });
this.allTab = page.getByRole("tab", { name: "All" });
this.personalizedTab = page.getByRole("tab", { name: "Personalized" });
this.disabledTab = page.getByRole("tab", { name: "Disabled" });
this.restoreAllButton = page.getByRole("button", {
name: /restore all/i,
});
this.importExportButton = page.getByRole("button", {
name: /import\/export/i,
});
this.fileInput = page.locator('input[type="file"]');
}
async goToShortcuts() {
await this.page.goto("/?screen=settings-shortcuts");
await expect(this.shortcutsSection).toBeVisible();
}
async searchForShortcut(term) {
await this.searchInput.fill(term);
}
async clearSearch() {
await this.searchInput.clear();
}
async clickTab(tabName) {
const tab = this.page.getByRole("tab", { name: tabName });
await tab.click();
}
async getShortcutRow(shortcutName) {
return this.page.getByRole("listitem", {
name: new RegExp(`^${shortcutName}$`, "i"),
includeHidden: true,
});
}
async expandSubsection(subsectionName) {
const subsectionButton = this.page.getByRole("button", {
name: subsectionName,
});
await subsectionButton.click();
}
async clickEditShortcut(shortcutName) {
const button = this.page.getByRole("button", {
name: new RegExp(`Edit ${shortcutName}`, "i"),
});
await button.click();
const recordingArea = this.page.getByText("Press the key combination");
await expect(recordingArea).toBeVisible();
await recordingArea.focus();
}
async pressKey(key) {
await this.page.keyboard.press(key);
}
async pressKeyCombo(keys) {
for (const key of keys) {
await this.page.keyboard.down(key);
}
for (const key of [...keys].reverse()) {
await this.page.keyboard.up(key);
}
}
async saveShortcut() {
const saveButton = this.page.getByRole("button", { name: /save/i });
await saveButton.click();
}
async cancelEdit() {
const cancelButton = this.page.getByRole("button", { name: /cancel/i });
await cancelButton.click();
}
async resetShortcut(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
const resetButton = row.getByRole("button", { name: /reset/i });
await resetButton.click();
}
async disableShortcut(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await row
.getByRole("button", { name: new RegExp(`Edit ${shortcutName}`, "i") })
.click();
const disableButton = this.page.getByRole("button", { name: /disable/i });
await disableButton.click();
}
async expectShortcutCustomized(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-customized", "true");
}
async expectShortcutNotCustomized(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).not.toHaveAttribute("data-customized", "true");
}
async expectShortcutHasConflict(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-conflict", "true");
}
async expectShortcutVisible(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toBeVisible();
}
async expectShortcutHidden(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).not.toBeVisible();
}
async restoreAllShortcuts() {
await this.restoreAllButton.click();
const confirmButton = this.page.getByRole("button", {
name: "Restore",
exact: true,
});
await confirmButton.click();
}
async exportShortcuts() {
await this.importExportButton.click();
const exportButton = this.page.getByRole("menuitem", { name: /export/i });
await exportButton.click();
}
async importShortcuts(jsonData) {
await this.importExportButton.click();
const importButton = this.page.getByRole("menuitem", { name: /import$/i });
await importButton.click();
const buffer = Buffer.from(JSON.stringify(jsonData), "utf-8");
await this.fileInput.setInputFiles({
name: "shortcuts.json",
mimeType: "application/json",
buffer,
});
}
async importShortcutsRaw(content) {
await this.importExportButton.click();
const importButton = this.page.getByRole("menuitem", { name: /import$/i });
await importButton.click();
const data =
typeof content === "string" ? content : JSON.stringify(content);
const buffer = Buffer.from(data, "utf-8");
await this.fileInput.setInputFiles({
name: "shortcuts.json",
mimeType: "application/json",
buffer,
});
}
async confirmImportApply() {
const applyButton = this.page.getByRole("button", {
name: /apply/i,
});
await applyButton.click();
}
async getExportedJson() {
const [download] = await Promise.all([
this.page.waitForEvent("download"),
this.exportShortcuts(),
]);
expect(download.suggestedFilename()).toMatch(
/^penpot-shortcuts-Princesa_Leia-\d{4}-\d{2}-\d{2}\.json$/,
);
const path = await download.path();
const content = await readFile(path, "utf-8");
return JSON.parse(content);
}
async expectShortcutDisabled(shortcutName) {
const row = await this.getShortcutRow(shortcutName);
await expect(row).toHaveAttribute("data-customized", "true");
await expect(row.locator("use[href*='broken-link']")).toBeVisible();
}
}
export default ShortcutsPage;