mirror of
https://github.com/penpot/penpot.git
synced 2026-09-19 02:16:14 +00:00
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
338 lines
9.1 KiB
JavaScript
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;
|