Skip to content
14 changes: 14 additions & 0 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
listItemKeyStartsWithSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
workspacePinToggleSelector,
} from "./selectors"
import type { createSdk } from "./utils"

Expand Down Expand Up @@ -576,3 +577,16 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
await expect(menu).toBeVisible()
return menu
}

export async function setWorkspacePinned(page: Page, workspaceSlug: string, enabled: boolean) {
const menu = await openWorkspaceMenu(page, workspaceSlug)
const toggle = menu.locator(workspacePinToggleSelector(workspaceSlug)).first()
await expect(toggle).toBeVisible()
const name = await toggle.textContent()
const pinned = (name ?? "").toLowerCase().includes("unpin")
if (pinned === enabled) {
await page.keyboard.press("Escape")
return
}
await toggle.click({ force: true })
}
276 changes: 275 additions & 1 deletion packages/app/e2e/projects/workspaces.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,43 @@ import { test, expect } from "../fixtures"

test.describe.configure({ mode: "serial" })
import {
createTestProject,
cleanupTestProject,
clickMenuItem,
confirmDialog,
openSidebar,
openWorkspaceMenu,
setWorkspacePinned,
setWorkspacesEnabled,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import {
dropdownMenuContentSelector,
inlineInputSelector,
projectSwitchSelector,
workspaceDividerSelector,
workspaceItemSelector,
} from "../selectors"
import { createSdk, dirSlug } from "../utils"

function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}

async function ensureWorkspacesEnabled(page: Page, slug: string) {
for (const _ of [0, 1, 2]) {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
const visible = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (visible) return
}
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible({ timeout: 60_000 })
}

async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
Expand Down Expand Up @@ -302,6 +325,257 @@ test("can delete a workspace", async ({ page, withProject }) => {
})
})

test("can pin and unpin a workspace with persistence", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
await ensureWorkspacesEnabled(page, rootSlug)

const workspaces = [] as string[]
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)

workspaces.push(slugFromUrl(page.url()))
await openSidebar(page)
}

const a = workspaces[0]
const b = workspaces[1]
if (!a || !b) throw new Error("Expected two created workspaces")

const key = (slug: string) => {
return base64Decode(slug)
.replace(/[\\/]+/g, "/")
.replace(/\/+$/, "")
.toLowerCase()
}

const aKey = key(a)
const bKey = key(b)
const rootKey = key(rootSlug)

const list = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
const seen = new Set<string>()
return slugs
.filter((slug) => {
const slugKey = key(slug)
if (seen.has(slugKey)) return false
seen.add(slugKey)
return true
})
.filter((slug) => {
const slugKey = key(slug)
return slugKey === aKey || slugKey === bKey
})
}

const listAll = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
const seen = new Set<string>()
return slugs
.filter((slug) => {
const slugKey = key(slug)
if (seen.has(slugKey)) return false
seen.add(slugKey)
return true
})
.filter((slug) => {
const slugKey = key(slug)
return slugKey === rootKey || slugKey === aKey || slugKey === bKey
})
}

const find = async (target: string) => {
const slugs = await listAll()
return slugs.find((slug) => key(slug) === target)
}

await expect.poll(async () => (await list()).length).toBe(2)
const before = await list()
const aSlug = await find(aKey)
if (!aSlug) throw new Error("Missing first workspace slug")

await setWorkspacePinned(page, aSlug, true)
await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey])

await setWorkspacePinned(page, rootSlug, false)
await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(aKey)

await setWorkspacePinned(page, rootSlug, true)
await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(rootKey)

await page.reload()
await openSidebar(page)
await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey])

const pinnedSlug = await find(aKey)
if (!pinnedSlug) throw new Error("Missing pinned workspace slug")
await setWorkspacePinned(page, pinnedSlug, false)
await expect.poll(async () => await list()).toEqual(before)
})
})

test("workspace pinning is isolated per project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })

const other = await createTestProject()
const otherSlug = dirSlug(other)
const dirs = [] as string[]
const key = (slug: string) =>
base64Decode(slug)
.replace(/[\\/]+/g, "/")
.replace(/\/+$/, "")
.toLowerCase()

try {
await withProject(
async ({ slug }) => {
await ensureWorkspacesEnabled(page, slug)

await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")

const pinnedSlug = slugFromUrl(page.url())
dirs.push(base64Decode(pinnedSlug))
const pinnedKey = key(pinnedSlug)

await openSidebar(page)
await setWorkspacePinned(page, pinnedSlug, true)

const pinnedMenu = await openWorkspaceMenu(page, pinnedSlug)
await expect(
pinnedMenu
.getByRole("menuitem")
.filter({ hasText: /^Unpin$/i })
.first(),
).toBeVisible()
await page.keyboard.press("Escape")

const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))

await ensureWorkspacesEnabled(page, otherSlug)

await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === otherSlug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")

const otherWorkspace = slugFromUrl(page.url())
dirs.push(base64Decode(otherWorkspace))

await openSidebar(page)
const otherMenu = await openWorkspaceMenu(page, otherWorkspace)
await expect(otherMenu.getByRole("menuitem").filter({ hasText: /^Pin$/i }).first()).toBeVisible()
await page.keyboard.press("Escape")

const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()

await openSidebar(page)
const slugs = await page
.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
const rootSlug = slugs.find((slug) => key(slug) === pinnedKey)
if (!rootSlug) throw new Error("Could not find pinned workspace in original project")

const rootMenu = await openWorkspaceMenu(page, rootSlug)
await expect(
rootMenu
.getByRole("menuitem")
.filter({ hasText: /^Unpin$/i })
.first(),
).toBeVisible()
},
{ extra: [other] },
)
} finally {
await Promise.all(dirs.map((dir) => cleanupTestProject(dir)))
await cleanupTestProject(other)
}
})

test("workspace divider is shown only with mixed pin state", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })

await withProject(async ({ slug: rootSlug }) => {
await ensureWorkspacesEnabled(page, rootSlug)

const workspaces = [] as string[]
try {
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)

workspaces.push(slugFromUrl(page.url()))
await openSidebar(page)
}

const a = workspaces[0]
const b = workspaces[1]
if (!a || !b) throw new Error("Expected two created workspaces")

await setWorkspacePinned(page, rootSlug, false)
await setWorkspacePinned(page, a, true)
await setWorkspacePinned(page, b, false)
await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBeGreaterThan(0)

await setWorkspacePinned(page, a, false)
await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBe(0)
} finally {
await Promise.all(workspaces.map((slug) => cleanupTestProject(base64Decode(slug))))
}
})
})

test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
Expand Down
5 changes: 5 additions & 0 deletions packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector}
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`

export const workspaceDividerSelector = `${sidebarNavSelector} [data-component="workspace-item"][data-workspace-divider="true"]`

export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`

export const workspacePinToggleSelector = (slug: string) =>
`[data-action="workspace-pin-toggle"][data-workspace="${slug}"]`

export const workspaceNewSessionSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]`

Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,8 @@ export const dict = {
"session.delete.button": "Delete session",

"workspace.new": "New workspace",
"workspace.pin": "Pin",
"workspace.unpin": "Unpin",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace",
Expand Down
Loading
Loading