feat: add host-local custom project icons (#2335)
* feat(projects): add local icons and colors * refactor(projects): simplify appearance handling * fix(projects): serialize appearance saves * test(app): update project appearance expectations * test: fix project appearance CI expectations * refactor(projects): organize appearance code * fix(server): adopt main's reconcile model in project appearance updates * fix(projects): preserve host-local appearance targets * fix(desktop): restore Linux development startup Ubuntu can block Electron's unprivileged user-namespace fallback while the npm-installed sandbox helper is not SUID. Disable the Chromium process sandbox only in the Linux development runner so packaged desktop policy remains unchanged. * feat(projects): add custom project icons Keep URL acquisition in the client so the host daemon only validates uploaded bytes. Project name and icon changes share one adaptive edit sheet. * refactor(settings): scope projects by host --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
706ae3d9bb
commit
800bd2bef1
@@ -2,7 +2,9 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { addConnectedHostsAndReload, waitForConnectedHost } from "./hosts";
|
||||
import { openProjectSettings } from "./project-settings";
|
||||
import { selectSettingsHost } from "./settings";
|
||||
import { waitForSidebarHydration } from "./workspace-ui";
|
||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
const PROJECT_VISIBILITY_TIMEOUT = 30_000;
|
||||
|
||||
@@ -98,34 +100,37 @@ export async function expectProjectWorkspaceCountForHost(
|
||||
}
|
||||
|
||||
export async function renameProject(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("button", { name: "Rename project", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Edit project", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Project name", exact: true }).fill(name);
|
||||
await page.getByRole("button", { name: "Save project name", exact: true }).click();
|
||||
await expect(page.getByText("Project renamed", { exact: true })).toBeVisible({
|
||||
await page.getByRole("button", { name: "Save changes", exact: true }).click();
|
||||
await expect(page.getByText("Project updated", { exact: true })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openGroupedProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
export async function openGroupedProjectSettings(
|
||||
page: Page,
|
||||
input: { serverId: string; projectName: string },
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await openProjectSettings(page, projectName);
|
||||
await selectSettingsHost(page, input.serverId);
|
||||
await page.locator('[data-testid="settings-host-section-projects"]:visible').click();
|
||||
await expect(page).toHaveURL(/\/settings\/hosts\/[^/]+\/projects$/);
|
||||
await openProjectSettings(page, input.projectName);
|
||||
}
|
||||
|
||||
export async function switchProjectSettingsHost(page: Page, hostName: string): Promise<void> {
|
||||
const projectSettings = page.getByRole("main");
|
||||
await projectSettings.getByRole("button", { name: "Switch host", exact: true }).click();
|
||||
await page.getByRole("button", { name: hostName, exact: true }).click();
|
||||
await expect(
|
||||
projectSettings.getByRole("button", { name: "Switch host", exact: true }),
|
||||
).toContainText(hostName);
|
||||
export async function openProjectsForSettingsHost(
|
||||
page: Page,
|
||||
input: { serverId: string; projectName: string },
|
||||
): Promise<void> {
|
||||
await selectSettingsHost(page, input.serverId);
|
||||
await expect(page).toHaveURL(buildProjectsSettingsRoute(input.serverId));
|
||||
await openProjectSettings(page, input.projectName);
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsName(page: Page, projectName: string): Promise<void> {
|
||||
await expect(page.getByRole("group", { name: "Project name", exact: true })).toContainText(
|
||||
projectName,
|
||||
{ timeout: PROJECT_VISIBILITY_TIMEOUT },
|
||||
);
|
||||
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible({
|
||||
timeout: PROJECT_VISIBILITY_TIMEOUT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { buildProjectsSettingsRoute } from "@/utils/host-routes";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -33,8 +35,8 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
await page.getByRole("button", { name: "Projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
@@ -48,6 +50,21 @@ export async function navigateToProjectSettings(page: Page, projectName: string)
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function returnToProjectsList(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back to projects", exact: true }).click();
|
||||
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsHistoryRoundTrip(
|
||||
page: Page,
|
||||
projectName: string,
|
||||
): Promise<void> {
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(buildProjectsSettingsRoute(getServerId()));
|
||||
await page.goForward();
|
||||
await expectProjectTitle(page, projectName);
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
@@ -57,7 +74,7 @@ export async function editWorktreeSetup(page: Page, setupCommands: string[]): Pr
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await page.getByTestId("save-button").click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
@@ -72,6 +89,61 @@ export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Project edit sheet (name + icon) ---
|
||||
|
||||
export async function openProjectEditSheet(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Edit project", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillProjectName(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Project name" }).fill(name);
|
||||
}
|
||||
|
||||
export async function chooseProjectIconImage(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
||||
await page.getByRole("button", { name: "Choose image" }).click();
|
||||
await (await chooserPromise).setFiles([file]);
|
||||
await expect(page.getByText(file.name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillProjectIconUrl(page: Page, url: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Image or website URL" }).fill(url);
|
||||
}
|
||||
|
||||
export async function useAutomaticProjectIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Use automatic" }).click();
|
||||
}
|
||||
|
||||
export async function saveProjectEdits(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save changes" }).click();
|
||||
}
|
||||
|
||||
export async function expectProjectEditName(page: Page, name: string): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Project name" })).toHaveValue(name);
|
||||
}
|
||||
|
||||
export async function expectProjectEditsSaveDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
}
|
||||
|
||||
export async function expectProjectEditSaved(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("Project updated");
|
||||
}
|
||||
|
||||
// The sheet keeps the user's input on a failed save so the value stays editable.
|
||||
export async function expectProjectEditFailed(page: Page, detail: string): Promise<void> {
|
||||
await expect(page.getByText(detail, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Project name" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectProjectTitle(page: Page, projectName: string): Promise<void> {
|
||||
await expect(page.getByRole("main").getByText(projectName, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
@@ -102,7 +174,7 @@ export async function expectWriteFailedCalloutActions(page: Page): Promise<void>
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
await expect(page.getByTestId("save-button")).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
@@ -121,13 +193,8 @@ export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
export async function expectProjectHostContextHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).not.toBeVisible();
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { buildCreateAgentPreferences, buildSeededHost, TEST_HOST_LABEL } from ".
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import {
|
||||
buildProjectsSettingsRoute,
|
||||
buildSettingsHostSectionRoute,
|
||||
buildSettingsRoute,
|
||||
buildSettingsSectionRoute,
|
||||
@@ -30,20 +29,21 @@ const SECTION_LABELS = {
|
||||
about: "About",
|
||||
} as const;
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS;
|
||||
|
||||
type HostSection = "connections" | "agents" | "workspaces" | "providers" | "usage" | "host";
|
||||
type HostSection =
|
||||
| "projects"
|
||||
| "connections"
|
||||
| "agents"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
| "usage"
|
||||
| "host";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expectAppRoute(page, buildProjectsSettingsRoute());
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expectAppRoute(page, buildSettingsSectionRoute(section));
|
||||
}
|
||||
@@ -143,8 +143,8 @@ export async function seedSavedSettingsHosts(
|
||||
}
|
||||
|
||||
export async function selectSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId("settings-host-picker").click();
|
||||
await page.getByTestId(`settings-host-picker-item-${serverId}`).click();
|
||||
await page.locator('[data-testid="settings-host-picker"]:visible').click();
|
||||
await page.locator(`[data-testid="settings-host-picker-item-${serverId}"]:visible`).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsHostPickerLabel(page: Page, label: string): Promise<void> {
|
||||
@@ -171,7 +171,7 @@ export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||
|
||||
export async function expectSettingsSidebarSections(
|
||||
page: Page,
|
||||
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||
sections: SettingsSection[],
|
||||
): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
for (const section of sections) {
|
||||
@@ -376,6 +376,7 @@ export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<vo
|
||||
|
||||
// Host group rows are now flat top-level sections (no drill-in).
|
||||
await expect(sidebar.getByTestId("settings-host-section-connections")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-projects")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-agents")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-workspaces")).toBeVisible();
|
||||
await expect(sidebar.getByTestId("settings-host-section-providers")).toBeVisible();
|
||||
|
||||
@@ -5,29 +5,40 @@ import { connectSeedClient, seedWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
chooseProjectIconImage,
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectHostIndicatorVisible,
|
||||
expectHostPickerHidden,
|
||||
expectProjectHostContextHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectProjectEditFailed,
|
||||
expectProjectEditName,
|
||||
expectProjectEditSaved,
|
||||
expectProjectEditsSaveDisabled,
|
||||
expectProjectSettingsError,
|
||||
expectProjectSettingsFormHidden,
|
||||
expectProjectSettingsFormVisible,
|
||||
expectProjectTitle,
|
||||
expectProjectSettingsHistoryRoundTrip,
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
fillProjectIconUrl,
|
||||
fillProjectName,
|
||||
installDaemonConnectionGate,
|
||||
installReadTransportFailure,
|
||||
navigateToProjectSettings,
|
||||
openProjectEditSheet,
|
||||
openProjectSettings,
|
||||
openProjects,
|
||||
removeProjectScript,
|
||||
restorePaseoConfig,
|
||||
returnToProjectsList,
|
||||
saveProjectEdits,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
@@ -40,6 +51,12 @@ import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
// Smallest valid square PNG the daemon will accept as a custom project icon.
|
||||
const PNG_1X1 = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
|
||||
interface ProjectsSettingsProject {
|
||||
name: string;
|
||||
path: string;
|
||||
@@ -204,6 +221,15 @@ test.describe("Projects settings", () => {
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
@@ -215,6 +241,74 @@ test.describe("Projects settings", () => {
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
|
||||
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
|
||||
test("reopening the edit sheet seeds from the saved project", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
|
||||
await openProjectEditSheet(page);
|
||||
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
|
||||
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
});
|
||||
|
||||
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Both at once");
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Both at once");
|
||||
});
|
||||
|
||||
test("project edit keeps a rejected icon URL actionable", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectIconUrl(page, "file:///etc/passwd");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditFailed(page, "URL must use HTTP or HTTPS without credentials");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Projects settings — error UX", () => {
|
||||
@@ -322,15 +416,14 @@ test.describe("Projects settings — error UX", () => {
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("single-host project renders static host indicator, not a picker chip", async ({
|
||||
test("project detail does not render a second host selector", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectHostIndicatorVisible(page);
|
||||
await expectHostPickerHidden(page);
|
||||
await expectProjectHostContextHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
expectProjectWorkspaceCountForHost,
|
||||
expectSeparateProjects,
|
||||
openGroupedProjectSettings,
|
||||
openProjectsForSettingsHost,
|
||||
openProjectDirectory,
|
||||
openProjectDirectoryWithHosts,
|
||||
renameProject,
|
||||
selectWorkspaceHost,
|
||||
switchProjectSettingsHost,
|
||||
} from "./helpers/project-grouping";
|
||||
import { type IsolatedHostDaemon, startIsolatedHostDaemon } from "./helpers/isolated-host-daemon";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
@@ -380,10 +380,18 @@ test.describe("Sidebar project grouping", () => {
|
||||
|
||||
test("renames only the selected host's grouped project", async ({ page, crossHostProject }) => {
|
||||
await openScenario(page, crossHostProject);
|
||||
await openGroupedProjectSettings(page, GROUPED_PROJECT_NAME);
|
||||
await openGroupedProjectSettings(page, {
|
||||
serverId: getServerId(),
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await renameProject(page, "Primary-only project name");
|
||||
await expectProjectSettingsName(page, "Primary-only project name");
|
||||
await switchProjectSettingsHost(page, SECONDARY_HOST_LABEL);
|
||||
const secondaryHost = crossHostProject.hosts[0];
|
||||
if (!secondaryHost) throw new Error("Expected a secondary host");
|
||||
await openProjectsForSettingsHost(page, {
|
||||
serverId: secondaryHost.serverId,
|
||||
projectName: GROUPED_PROJECT_NAME,
|
||||
});
|
||||
await expectProjectSettingsName(page, GROUPED_PROJECT_NAME);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user