Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions frontend/e2e/atlas-perspectives.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { chromium, expect, test } from '@playwright/test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
ATLAS_PERSPECTIVES_MCP_BASE_PORT,
ATLAS_PERSPECTIVES_SERVER_BASE_PORT,
spawnMillServer,
type SpawnedServer,
} from './fixtures/server'
import { contextMenu } from './fixtures/contextMenu'
import { groupCard, noteCard, openCard } from './fixtures/atlasBoard'
import { deleteViaPageMenu } from './fixtures/atlasPage'
import { ATLAS_KIND_TOPIC, selectKind } from './fixtures/kindPicker'

// Perspectives (ADR-0041, goal 0095 slice 2): the switcher (which
// absorbed the old Lens popover), board membership filtering incl. the
// ancestry-closure and link rules, arrange-disabled-while-active,
// authoring-adds-to-active, and membership editing from both the
// board's context menu and the card page. The active perspective is
// GLOBAL Atlas session state (AtlasSessionState.activePerspectiveID)
// and every perspective record is read by every board render -- its
// own dedicated server pair (fixtures/server.ts's ATLAS_PERSPECTIVES_*
// ports), never the shared worker pool (testing.md's shared-vs-
// dedicated rule).
//
// Runs against the seeded "My space" tree (internal/domain/atlas/
// builtin.go): My space (root) holds Getting started, Scratchpad, and
// Example area (which holds Ada Lovelace and Project charter); a
// seeded link connects Getting started -> Ada Lovelace.

async function withServer(testInfo: { parallelIndex: number }, run: (page: Awaited<ReturnType<import('@playwright/test').Browser['newPage']>>) => Promise<void>): Promise<void> {
const idx = testInfo.parallelIndex
const dir = mkdtempSync(path.join(tmpdir(), `mill-e2e-atlas-perspectives-${idx}-`))
const settingsPath = path.join(dir, 'settings.json')
const executionDbPath = path.join(dir, 'execution.db')
const backupDir = path.join(dir, 'backups')
const port = ATLAS_PERSPECTIVES_SERVER_BASE_PORT + idx
const mcpPort = ATLAS_PERSPECTIVES_MCP_BASE_PORT + idx

let server: SpawnedServer | undefined
const browser = await chromium.launch()
try {
server = await spawnMillServer({ port, mcpPort, settingsPath, executionDbPath, backupDir })
const page = await browser.newPage()
await page.goto(`${server.baseURL}/`)
await page.getByRole('link', { name: 'Atlas' }).click()
await expect(page.getByTestId('atlas-board')).toBeVisible()
await run(page)
} finally {
await browser.close()
await server?.stop()
rmSync(dir, { recursive: true, force: true })
}
}

const switcherButton = (page: import('@playwright/test').Page) => page.getByTestId('atlas-perspective-switcher-open')
const switcherPopover = (page: import('@playwright/test').Page) => page.getByTestId('atlas-perspective-switcher-popover')

async function createPerspective(page: import('@playwright/test').Page, name: string): Promise<void> {
await switcherButton(page).click()
await expect(switcherPopover(page)).toBeVisible()
await page.getByTestId('atlas-perspective-new-input').fill(name)
await page.getByTestId('atlas-perspective-new-input').press('Enter')
await expect(switcherButton(page)).toHaveText(name)
}

// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
test('create, switch, and rename a perspective via the switcher', async ({}, testInfo) => {
await withServer(testInfo, async (page) => {
await createPerspective(page, 'Current')

// Switch back to All cards: the switcher label reverts and the
// popover's own "All cards" row reads selected.
await switcherButton(page).click()
await switcherPopover(page).getByText('All cards', { exact: true }).click()
await expect(switcherButton(page)).toHaveText('All cards')

// Switch back to the created perspective by name.
await switcherButton(page).click()
await switcherPopover(page).getByText('Current', { exact: true }).click()
await expect(switcherButton(page)).toHaveText('Current')

// Rename inline via the row's own right-click menu.
await switcherButton(page).click()
await switcherPopover(page).getByText('Current', { exact: true }).click({ button: 'right' })
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Rename', { exact: true }).click()
const renameInput = page.getByTestId('atlas-perspective-rename-input')
await expect(renameInput).toBeVisible()
await renameInput.fill('Renamed')
await renameInput.press('Enter')
await expect(switcherButton(page)).toHaveText('Renamed')
})
})

// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
test('a perspective filters the board to its members closed under ancestry, and hides a link whose endpoint is filtered out; Auto-arrange disables while active', async ({}, testInfo) => {
await withServer(testInfo, async (page) => {
await createPerspective(page, 'Filtered')

// Nothing is a member yet -- the board renders empty.
await expect(noteCard(page, 'Getting started')).not.toBeVisible()
await expect(noteCard(page, 'Scratchpad')).not.toBeVisible()
await expect(groupCard(page, 'Example area')).not.toBeVisible()

// Arrange-disabled-while-active (ADR-0041): aria-disabled + the
// exact tooltip copy, re-enabled once back on All cards.
const arrange = page.getByTestId('atlas-auto-arrange')
await expect(arrange).toHaveAttribute('aria-disabled', 'true')
await expect(arrange).toHaveAttribute('title', 'Arranging works on all cards. Switch to All cards first.')

// Switch to All cards to reach "Getting started" and add it via
// the board's own right-click "Add to perspective".
await switcherButton(page).click()
await switcherPopover(page).getByText('All cards', { exact: true }).click()
await expect(arrange).not.toHaveAttribute('aria-disabled', 'true')

await noteCard(page, 'Getting started').click({ button: 'right' })
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Add to perspective', { exact: false }).click()
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Filtered', { exact: true }).click()
await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Added to Filtered')

// Reach "Ada Lovelace" (nested inside Example area) and add her via
// her own card page's membership chip -- still on All cards.
await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
await openCard(page, noteCard(page, 'Ada Lovelace'))
const overlay = page.locator('[data-component="atlas-card-overlay"]')
await expect(overlay).toBeVisible()
// The add menu's own ActionMenu.Overlay portals outside the
// Dialog's DOM subtree (the same reason fixtures/atlasPage.ts's
// deleteViaPageMenu queries the kebab's menu item off `page`, not
// `overlay`) -- the item is queried off `page` here too.
await overlay.getByTestId('atlas-page-perspective-add').click()
await page.getByRole('menuitem', { name: 'Filtered', exact: true }).click()
await expect(overlay.getByTestId('atlas-page-perspective-membership')).toContainText('Filtered')
await page.keyboard.press('Escape')
await expect(overlay).not.toBeVisible()
await page.getByTestId('atlas-breadcrumb').getByText('My space', { exact: true }).click()

// Switch to Filtered: Getting started (direct add) AND Example area
// (ancestry closure from Ada's own add) both render; Project
// charter (Ada's sibling, never added) and the seeded Getting
// started -> Ada Lovelace link (never made a perspective member
// itself) both stay hidden -- the endpoint-visibility half of the
// link rule already covers this: Ada's own top-level ancestor
// (Example area) IS visible here, but the raw link record was
// never added to Filtered's own MemberLinkIDs.
await switcherButton(page).click()
await switcherPopover(page).getByText('Filtered', { exact: true }).click()
await expect(noteCard(page, 'Getting started')).toBeVisible()
await expect(groupCard(page, 'Example area')).toBeVisible()
await expect(noteCard(page, 'Scratchpad')).not.toBeVisible()
await expect(page.locator('.react-flow__edge')).toHaveCount(0)

await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
await expect(noteCard(page, 'Ada Lovelace')).toBeVisible()
await expect(noteCard(page, 'Project charter')).not.toBeVisible()
})
})

// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
test('authoring a card while a perspective is active adds it automatically; the edit reflects after switching back (the O(1) property)', async ({}, testInfo) => {
await withServer(testInfo, async (page) => {
await createPerspective(page, 'Authoring')

const title = 'ZzE2ePerspectiveAuthored'
await page.getByTestId('atlas-add-button').click()
await page.getByTestId('atlas-add-child').click()
await selectKind(page, ATLAS_KIND_TOPIC, 'atlas-create-kind')
await page.getByTestId('atlas-create-title').fill(title)
await page.getByRole('button', { name: 'Create' }).click()

// Authoring-adds-to-active (server-side, keyed off the session's
// own ActivePerspectiveID) -- the new card renders immediately on
// the still-filtered board, no separate add step.
const newCard = noteCard(page, title)
await expect(newCard).toBeVisible()

// Edit it while the perspective is still active.
await openCard(page, newCard)
const overlay = page.locator('[data-component="atlas-card-overlay"]')
await overlay.getByTestId('atlas-page-note').fill('Edited while a perspective was active.')
await overlay.getByTestId('atlas-page-note').blur()
await expect(overlay.getByTestId('atlas-page-saved-tick')).toBeVisible()
await page.keyboard.press('Escape')
await expect(overlay).not.toBeVisible()

// One record, every view: the same edit shows on All cards too.
await switcherButton(page).click()
await switcherPopover(page).getByText('All cards', { exact: true }).click()
await openCard(page, newCard)
await expect(page.getByTestId('atlas-page-note')).toHaveValue('Edited while a perspective was active.')

// Cleanup (testing.md's within-file discipline) -- overlay already open.
await deleteViaPageMenu(page, page.locator('[data-component="atlas-card-overlay"]'))
await expect(newCard).toHaveCount(0)
})
})

// eslint-disable-next-line no-empty-pattern -- this test needs `testInfo` (the second arg), not any fixture.
test('membership removes via the board context menu, and deleting a perspective returns the board to All cards', async ({}, testInfo) => {
await withServer(testInfo, async (page) => {
await createPerspective(page, 'Removable')

await switcherButton(page).click()
await switcherPopover(page).getByText('All cards', { exact: true }).click()
await noteCard(page, 'Scratchpad').click({ button: 'right' })
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Add to perspective', { exact: false }).click()
await contextMenu(page).getByText('Removable', { exact: true }).click()
await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Added to Removable')

await switcherButton(page).click()
await switcherPopover(page).getByText('Removable', { exact: true }).click()
await expect(noteCard(page, 'Scratchpad')).toBeVisible()

await noteCard(page, 'Scratchpad').click({ button: 'right' })
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Remove from perspective', { exact: false }).click()
await contextMenu(page).getByText('Removable', { exact: true }).click()
await expect(page.getByTestId('atlas-quiet-toast')).toContainText('Removed from Removable')
await expect(noteCard(page, 'Scratchpad')).not.toBeVisible()

// Delete the perspective: blocked-delete-with-confirm posture --
// ConfirmDialog names it, confirming lands back on All cards.
await switcherButton(page).click()
await switcherPopover(page).getByText('Removable', { exact: true }).click({ button: 'right' })
await expect(contextMenu(page)).toBeVisible()
await contextMenu(page).getByText('Delete', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Delete Removable?' })).toBeVisible()
await page.getByRole('button', { name: 'Delete', exact: true }).click()
await expect(switcherButton(page)).toHaveText('All cards')
await expect(noteCard(page, 'Scratchpad')).toBeVisible()
})
})
39 changes: 7 additions & 32 deletions frontend/e2e/atlas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ test('create a child card, edit + persist it via the card page, then delete it',
await expect(newCard).not.toBeVisible()
})

test('the lens hides a kind within a space', async ({ page }) => {
test('the perspective switcher\'s Hide kinds section hides a kind within a space', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
Expand All @@ -284,22 +284,22 @@ test('the lens hides a kind within a space', async ({ page }) => {
const contactCard = noteCard(page, 'Ada Lovelace')
await expect(contactCard).toBeVisible()

await page.getByTestId('atlas-lens-open').click()
await expect(page.locator('[data-component="atlas-lens-dialog"]')).toBeVisible()
await page.getByTestId('atlas-perspective-switcher-open').click()
await expect(page.getByTestId('atlas-perspective-switcher-popover')).toBeVisible()
await page.getByRole('checkbox', { name: /Contact/ }).uncheck()
await page.keyboard.press('Escape')

await expect(contactCard).not.toBeVisible()

// Restore: re-show the kind so the space's lens doesn't leak into a
// later test in this same file/worker.
await page.getByTestId('atlas-lens-open').click()
await page.getByTestId('atlas-perspective-switcher-open').click()
await page.getByRole('checkbox', { name: /Contact/ }).check()
await page.keyboard.press('Escape')
await expect(contactCard).toBeVisible()
})

test('lens-hiding a kind never removes a region frame of that kind -- containment is a role, not a type', async ({ page }) => {
test('hiding a kind never removes a region frame of that kind -- containment is a role, not a type', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
const exampleArea = groupCard(page, 'Example area')
Expand All @@ -311,7 +311,7 @@ test('lens-hiding a kind never removes a region frame of that kind -- containmen
// the Topic LEAVES (Getting started, Scratchpad) but keep the area
// frame -- a place holding cards stays on the board regardless of
// its own kind.
await page.getByTestId('atlas-lens-open').click()
await page.getByTestId('atlas-perspective-switcher-open').click()
await page.getByRole('checkbox', { name: /Topic/ }).uncheck()
await page.keyboard.press('Escape')

Expand All @@ -320,7 +320,7 @@ test('lens-hiding a kind never removes a region frame of that kind -- containmen
await expect(exampleArea).toBeVisible()

// Restore for later tests in this worker.
await page.getByTestId('atlas-lens-open').click()
await page.getByTestId('atlas-perspective-switcher-open').click()
await page.getByRole('checkbox', { name: /Topic/ }).check()
await page.keyboard.press('Escape')
await expect(gettingStarted).toBeVisible()
Expand Down Expand Up @@ -431,31 +431,6 @@ test('Update now on the seeded mirror card runs its workflow through the normal
await expect(overlay).not.toBeVisible()
})

test('the lens depth toggle persists server-side across a reload', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
await expect(page.getByTestId('atlas-breadcrumb')).toContainText('Example area')

await page.getByTestId('atlas-lens-open').click()
await expect(page.locator('[data-component="atlas-lens-dialog"]')).toBeVisible()
await page.getByRole('button', { name: 'Peek into children' }).click()
await page.keyboard.press('Escape')

await page.reload()
await expect(atlasView(page)).toBeVisible()
await groupCard(page, 'Example area').getByTestId('atlas-group-header').click()
await page.getByTestId('atlas-lens-open').click()
await expect(page.getByRole('button', { name: 'Peek into children', pressed: true })).toBeVisible()
await page.keyboard.press('Escape')

// Restore: clear the peek toggle so it doesn't leak into a later test
// in this same file/worker (testing.md's within-file cleanup rule).
await page.getByTestId('atlas-lens-open').click()
await page.getByRole('button', { name: 'This level only' }).click()
await page.keyboard.press('Escape')
})

test('Quick Panel finds a seeded Atlas card by title', async ({ page }) => {
const mainPage = await page.context().newPage()
try {
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ test('every new Atlas command is reachable from the palette by its label', async
const labels = [
'Select all',
'Auto-arrange',
'Open lens',
'Open perspective switcher',
'Import atlas',
'Export atlas',
'Add cards from a folder',
Expand Down
9 changes: 9 additions & 0 deletions frontend/e2e/fixtures/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,12 @@ export const GUARDRAIL_REVIEW_SERVER_BASE_PORT = 10180
// 10250, so the MCP base must start beyond that or a computed server
// port can land on another test's MCP listener.
export const GUARDRAIL_REVIEW_MCP_BASE_PORT = 10300

// atlas-perspectives.spec.ts's own dedicated pair (goal 0095 slice 2,
// ADR-0041): the active perspective is GLOBAL Atlas session state
// (AtlasSessionState.activePerspectiveID), and its own perspective/
// membership records are read by every other Atlas spec's board --
// same shared-global-state reasoning as guardrail-authoring/
// atlas-session-restore above, applied to this feature's own writes.
export const ATLAS_PERSPECTIVES_SERVER_BASE_PORT = 10320
export const ATLAS_PERSPECTIVES_MCP_BASE_PORT = 10340
2 changes: 2 additions & 0 deletions frontend/src/atlas/AtlasCardOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AtlasCardPageHeader } from './AtlasCardPageHeader'
import { AtlasCardPageFields } from './AtlasCardPageFields'
import { AtlasCardPageContents } from './AtlasCardPageContents'
import { AtlasCardPageMetaRail } from './AtlasCardPageMetaRail'
import { AtlasPerspectiveMembership } from './AtlasPerspectiveMembership'
import { AtlasSlotRows } from './AtlasSlotRows'
import { useAtlasCardPageFileDrop } from './useAtlasCardPageFileDrop'
import { FILE_DROP_CONTEXT_CARD_PAGE } from './atlasFileDropShared'
Expand Down Expand Up @@ -264,6 +265,7 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl
cardID={displayedCard.ID} actionWorkflowIDs={actionWorkflowIDs}
onActionsChanged={commitActions}
/>
<AtlasPerspectiveMembership cardID={displayedCard.ID} />
<AtlasSlotRows
card={displayedCard}
allCards={allCards}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/atlas/AtlasJumpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import styles from './AtlasJumpDialog.module.css'

// The ⌘K jump-to-a-card dialog (goal 0072 slice B): a global "go
// anywhere" entry point over every card, regardless of the currently
// viewed space -- distinct from AtlasLensControl's own space-scoped
// Dialog. Purely controlled (goal 0071's registry surface-precedence
// reconciliation): `open` comes from AtlasView, which opens it off the
// viewed space -- distinct from AtlasPerspectiveSwitcher's own
// space-scoped popover. Purely controlled (goal 0071's registry
// surface-precedence reconciliation): `open` comes from AtlasView, which opens it off the
// atlas.jump command's own store signal (shared/uiSignalStore.ts) --
// this component no longer runs its own capture-phase window listener
// to win the ⌘K race against the app-wide command palette; dispatch
Expand Down
Loading
Loading