Compare commits

...

11 Commits

Author SHA1 Message Date
bymyself
8917704041 fix(e2e): move openSwitcherPanel to WorkspaceAuthHelper
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12940#discussion_r3445172630
2026-06-24 20:50:35 +00:00
bymyself
eeb859045b fix(e2e): extract workspace mock data to fixtures
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/12940#discussion_r3445170462
2026-06-24 20:50:14 +00:00
GitHub Action
8505651fce [automated] Apply ESLint and Oxfmt fixes 2026-06-19 23:36:07 +00:00
bymyself
7b06203e3a fix(e2e): drop two-switch UI test — teamWorkspaceStore.switchWorkspace reloads
teamWorkspaceStore.switchWorkspace() calls window.location.reload() after
clearing context. The second switch in the back-and-forth test never lands
because the page reloads and re-runs init with the fixture-default token mock.
Single-switch token persistence is already covered by the first test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:32:30 +00:00
bymyself
f1bc9937a2 fix(e2e): scope workspace clicks to switcher panel to prevent ambiguous matches
getByText() without scoping could match the trigger row label or other
text on the page. Scope all workspace name clicks to
getByTestId('workspace-switcher-panel') to ensure only the list item
button is targeted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:17:46 +00:00
bymyself
1ced138532 fix(e2e): fix panel sequencing and ACCESS_DENIED timing race
- Add Escape before re-opening profile popover to avoid toggle-close on
  double-click, and wait for workspace-switcher-panel to be visible before
  clicking an item (prevents stale-panel click timeouts)
- Remove intermediate 'original-token' assertion in ACCESS_DENIED test:
  the refresh timer fires at delay≈0 so the token is cleared before the
  poll can observe it — wait for callCount=2 instead then assert null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 23:02:52 +00:00
bymyself
4e6922aa79 fix(e2e): account for init auto-switch and use Team Workspace for explicit switches
teamWorkspaceStore.initialize() auto-selects Personal Workspace on boot and
calls switchWorkspace → /api/auth/token. useWorkspaceSwitch skips switchWorkspace
when the target is already active, so clicking Personal Workspace in the panel
after init is a no-op.

Fix:
- Add default /api/auth/token stub in the fixture to absorb the init call
- Switch to Team Workspace in tests (not the auto-selected Personal one) so
  the explicit click is always a genuine workspace change
- Per-test route mocks are matched LIFO and override the fixture default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:46:35 +00:00
GitHub Action
4089d1dc06 [automated] Apply ESLint and Oxfmt fixes 2026-06-19 22:46:35 +00:00
bymyself
eb12ec62bf fix(e2e): correct workspace switch click sequence and use polling assertions
- Add two-step open: profile popover → workspace-switcher-trigger → workspace item
  (clicking the trigger div opens the panel; only clicking an item calls switchWorkspace)
- Replace bare expect() after click with expect.poll() to wait for async switchWorkspace
- Import Page type from @playwright/test instead of inline import()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:46:35 +00:00
GitHub Action
c29221e97f [automated] Apply ESLint and Oxfmt fixes 2026-06-19 22:46:35 +00:00
bymyself
2b7f7fbb70 test(e2e): add cloud E2E tests for workspace auth refresh races
Covers token persistence, workspace-switch replacement, transient 500
preservation, and 403 ACCESS_DENIED session clearing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:46:35 +00:00
3 changed files with 297 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
export const mockWorkspacesRemoteConfig: RemoteConfig = {
team_workspaces_enabled: true
}
export const mockPersonalWorkspace: WorkspaceWithRole = {
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
}
export const mockTeamWorkspace: WorkspaceWithRole = {
id: 'ws-team',
name: 'Team Workspace',
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
export function makeWorkspaceTokenResponse(
workspace: WorkspaceWithRole,
token: string,
expiresInMs = 60 * 60 * 1000
): WorkspaceTokenResponse {
return {
token,
expires_at: new Date(Date.now() + expiresInMs).toISOString(),
workspace: {
id: workspace.id,
name: workspace.name,
type: workspace.type
},
role: workspace.role,
permissions: []
}
}
// On app boot, teamWorkspaceStore.initialize() auto-selects Personal Workspace
// and calls switchWorkspace → /api/auth/token. This default response absorbs
// that init call so per-test mocks only see explicit switches.
export const defaultWorkspaceTokenResponse = makeWorkspaceTokenResponse(
mockPersonalWorkspace,
'init-token'
)

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import {
defaultWorkspaceTokenResponse,
mockPersonalWorkspace,
mockTeamWorkspace,
mockWorkspacesRemoteConfig
} from '@e2e/fixtures/data/workspaceAuthFixtures'
export class WorkspaceAuthHelper {
constructor(private readonly page: Page) {}
async mockWorkspaceRoutes(): Promise<void> {
await this.page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockWorkspacesRemoteConfig)
})
)
await this.page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
workspaces: [mockPersonalWorkspace, mockTeamWorkspace]
})
})
})
// Default stub for the init-time auto-switch. Per-test mocks added via
// mockTokenRoute() take priority (Playwright matches routes LIFO).
await this.page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(defaultWorkspaceTokenResponse)
})
)
}
async mockTokenRoute(
handler: (route: Route) => void | Promise<void>
): Promise<void> {
await this.page.route('**/api/auth/token', handler)
}
// Opens the profile popover then the workspace-switcher panel.
// useWorkspaceSwitch skips switchWorkspace when the target is already active,
// so always switch to a workspace other than the auto-selected Personal one.
async openSwitcherPanel(): Promise<void> {
await this.page.keyboard.press('Escape')
await this.page.getByRole('button', { name: 'Current user' }).click()
await this.page.getByTestId('workspace-switcher-trigger').click()
await expect(
this.page.getByTestId('workspace-switcher-panel')
).toBeVisible()
}
}

View File

@@ -0,0 +1,181 @@
import { expect } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import {
makeWorkspaceTokenResponse,
mockTeamWorkspace
} from '@e2e/fixtures/data/workspaceAuthFixtures'
import { WorkspaceAuthHelper } from '@e2e/fixtures/helpers/WorkspaceAuthHelper'
const test = comfyPageFixture.extend<{ workspaceAuth: WorkspaceAuthHelper }>({
page: async ({ page }, use) => {
const helper = new WorkspaceAuthHelper(page)
await helper.mockWorkspaceRoutes()
await use(page)
},
workspaceAuth: async ({ page }, use) => {
await use(new WorkspaceAuthHelper(page))
}
})
test.describe('Workspace auth refresh', { tag: '@cloud' }, () => {
test('token is persisted to sessionStorage after switching workspace', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
await workspaceAuth.mockTokenRoute((route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(mockTeamWorkspace, 'team-token')
)
})
)
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBe('team-token')
const expiresAt = await page.evaluate(() =>
sessionStorage.getItem('Comfy.Workspace.ExpiresAt')
)
expect(Number(expiresAt)).toBeGreaterThan(Date.now())
})
test('transient token refresh failure preserves the active workspace session', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
// TOKEN_REFRESH_BUFFER_MS is 5 minutes. Setting expiresInMs just below the
// buffer means scheduleTokenRefresh computes delay ≈ 0 and fires immediately.
const expiresInMs = 5 * 60 * 1000 - 500
let callCount = 0
await workspaceAuth.mockTokenRoute((route) => {
callCount++
if (callCount === 1) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(
mockTeamWorkspace,
'original-token',
expiresInMs
)
)
})
}
return route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ message: 'Internal Server Error' })
})
})
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBe('original-token')
// The scheduled refresh fires immediately (token expires within buffer window).
// Wait for the second route call (the failing refresh) to complete, then verify
// the token is preserved — a transient 500 must not clear the session.
await expect
.poll(() => callCount, { timeout: 5000 })
.toBeGreaterThanOrEqual(2)
expect(
await page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token'))
).toBe('original-token')
})
test('permanent auth failure (ACCESS_DENIED) clears the workspace session', async ({
comfyPage,
workspaceAuth
}) => {
const page = comfyPage.page
// TOKEN_REFRESH_BUFFER_MS is 5 minutes. Setting expiresInMs just below the
// buffer means scheduleTokenRefresh computes delay ≈ 0 and fires immediately.
const expiresInMs = 5 * 60 * 1000 - 500
let callCount = 0
await workspaceAuth.mockTokenRoute((route) => {
callCount++
if (callCount === 1) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
makeWorkspaceTokenResponse(
mockTeamWorkspace,
'original-token',
expiresInMs
)
)
})
}
return route.fulfill({
status: 403,
contentType: 'application/json',
body: JSON.stringify({ message: 'Access denied' })
})
})
await comfyPage.toast.closeToasts()
await workspaceAuth.openSwitcherPanel()
await page
.getByTestId('workspace-switcher-panel')
.getByText('Team Workspace')
.click()
// Wait for both the switch (callCount=1) and the immediate refresh (callCount=2)
// to complete. The refresh fires at delay≈0 so token may already be cleared
// before we can assert the intermediate 'original-token' state.
await expect
.poll(() => callCount, { timeout: 5000 })
.toBeGreaterThanOrEqual(2)
// A 403 ACCESS_DENIED response must clear the workspace session entirely.
await expect
.poll(
() =>
page.evaluate(() => sessionStorage.getItem('Comfy.Workspace.Token')),
{ timeout: 5000 }
)
.toBeNull()
expect(
await page.evaluate(() =>
sessionStorage.getItem('Comfy.Workspace.Current')
)
).toBeNull()
})
})