From f3a0c544287add365cf4983a4c4cec396ab32a6a Mon Sep 17 00:00:00 2001 From: 7inh-bosch Date: Fri, 7 Aug 2026 15:34:33 +0700 Subject: [PATCH] Add home list, import/export, and image fallback E2E tests Expand Playwright coverage for home model/prototype lists (guest visibility, category filters, all six sort options, rename, contributor flows), import/export round-trips, admin prototype context-menu delete, and card image fallbacks. Add shared helpers, npm scripts, and TESTING.md documentation. --- .agents/TESTING.md | 35 ++ .agents/package.json | 8 +- .agents/playwright.config.ts | 1 + .agents/tests/helpers.ts | 623 ++++++++++++++++++- .agents/tests/home-model-list.spec.ts | 486 +++++++++++++++ .agents/tests/home-prototype-list.spec.ts | 349 +++++++++++ .agents/tests/home-prototypes.spec.ts | 34 +- .agents/tests/home-sections.spec.ts | 2 + .agents/tests/image-fallback.spec.ts | 94 +++ .agents/tests/import-export.spec.ts | 144 +++++ .agents/tests/layout.spec.ts | 2 + .agents/tests/prototype-context-menu.spec.ts | 82 +++ .agents/tests/prototype-dashboard.spec.ts | 11 +- 13 files changed, 1851 insertions(+), 20 deletions(-) create mode 100644 .agents/tests/home-model-list.spec.ts create mode 100644 .agents/tests/home-prototype-list.spec.ts create mode 100644 .agents/tests/image-fallback.spec.ts create mode 100644 .agents/tests/import-export.spec.ts create mode 100644 .agents/tests/prototype-context-menu.spec.ts diff --git a/.agents/TESTING.md b/.agents/TESTING.md index 2120d86c..ba94be69 100644 --- a/.agents/TESTING.md +++ b/.agents/TESTING.md @@ -21,6 +21,11 @@ npx playwright test # Specific suite npx playwright test tests/auth.spec.ts npx playwright test tests/vehicle-models.spec.ts +npx playwright test tests/import-export.spec.ts +npx playwright test tests/prototype-context-menu.spec.ts +npx playwright test tests/home-model-list.spec.ts +npx playwright test tests/home-prototype-list.spec.ts +npx playwright test tests/image-fallback.spec.ts # With screenshots on failure npx playwright test --screenshot=only-on-failure @@ -29,6 +34,31 @@ npx playwright test --screenshot=only-on-failure npx playwright test --headed ``` +### Windows (CMD / PowerShell) + +Playwright treats the file argument as a **regex**. Use **forward slashes** (not `\`) or `npm run`: + +```powershell +cd C:\repo\autowrx\.agents +npm run test:home-model-list +``` + +Or with `npx` (forward slashes work on Windows): + +```powershell +npx playwright test tests/home-model-list.spec.ts +npx playwright test tests/import-export.spec.ts +npx playwright test tests/prototype-context-menu.spec.ts +npx playwright test tests/home-prototype-list.spec.ts +npx playwright test tests/image-fallback.spec.ts +``` + +Run several suites: + +```powershell +npx playwright test tests/import-export.spec.ts tests/prototype-context-menu.spec.ts tests/home-model-list.spec.ts +``` + ## Test Suites | File | Coverage | @@ -38,6 +68,11 @@ npx playwright test --headed | `tests/prototype.spec.ts` | Create/Read/Update/Delete prototypes | | `tests/admin.spec.ts` | Admin panel: user management, site config | | `tests/site-config-restore-default.spec.ts` | Public config restore default (accept + cancel) | +| `tests/import-export.spec.ts` | Model/prototype export and import round-trips | +| `tests/prototype-context-menu.spec.ts` | Admin delete prototype via context menu | +| `tests/home-model-list.spec.ts` | Home model-list: guest visibility, logged-in UI, category filters, all 6 sort options, rename, My Contributions contributor/reader | +| `tests/home-prototype-list.spec.ts` | Home prototype-list: guest visibility, category tabs, My Prototypes filter, all 6 sort options, navigation | +| `tests/image-fallback.spec.ts` | Model and prototype cards fall back to default images when primary image fails to load | | `tests/layout.spec.ts` | Layout, responsive, visual snapshots | ## Environment diff --git a/.agents/package.json b/.agents/package.json index c20b2d7c..90c1d5af 100644 --- a/.agents/package.json +++ b/.agents/package.json @@ -10,7 +10,13 @@ "test:admin": "playwright test tests/admin.spec.ts", "test:layout": "playwright test tests/layout.spec.ts", "test:site-config": "playwright test tests/site-config-restore-default.spec.ts", - "test:smoke": "playwright test tests/auth.spec.ts tests/layout.spec.ts" + "test:import-export": "playwright test tests/import-export.spec.ts", + "test:prototype-context-menu": "playwright test tests/prototype-context-menu.spec.ts", + "test:home-model-list": "playwright test tests/home-model-list.spec.ts", + "test:home-prototype-list": "playwright test tests/home-prototype-list.spec.ts", + "test:image-fallback": "playwright test tests/image-fallback.spec.ts", + "test:smoke": "playwright test tests/auth.spec.ts tests/layout.spec.ts", + "test:all": "playwright test --reporter=line,html" }, "devDependencies": { "@playwright/test": "^1.40.0", diff --git a/.agents/playwright.config.ts b/.agents/playwright.config.ts index 76f5d2ae..008fe5e7 100644 --- a/.agents/playwright.config.ts +++ b/.agents/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ timeout: 60000, expect: { timeout: 8000 }, fullyParallel: false, + workers: 1, retries: 1, reporter: [['line'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], use: { diff --git a/.agents/tests/helpers.ts b/.agents/tests/helpers.ts index f1b69ab0..12de9aef 100644 --- a/.agents/tests/helpers.ts +++ b/.agents/tests/helpers.ts @@ -1,5 +1,6 @@ -import { copyFile, mkdir, readFile } from 'fs/promises'; +import { copyFile, mkdir, readFile, rm } from 'fs/promises'; import { join } from 'path'; +import { tmpdir } from 'os'; import { Locator, Page, expect } from '@playwright/test'; export const E2E_PLUGIN_MARKER = 'E2E_PLUGIN_LOADED_OK'; @@ -44,6 +45,13 @@ export async function loginAs(page: Page, email: string, password: string) { await page.goto('/'); await page.waitForTimeout(1500); + const userProfile = page.locator('img[alt="User profile"]').first(); + if (await userProfile.isVisible().catch(() => false)) { + await logout(page); + await page.goto('/'); + await page.waitForTimeout(1500); + } + // Open login modal const signInBtn = page.locator('button:has-text("Sign In"), a:has-text("Sign In")').first(); await signInBtn.click(); @@ -53,7 +61,8 @@ export async function loginAs(page: Page, email: string, password: string) { await page.locator('input[type="password"]').first().fill(password); // Press Enter to submit (more reliable than clicking button with overlay) await page.locator('input[type="password"]').first().press('Enter'); - await page.waitForTimeout(2500); + await expect(page.locator('img[alt="User profile"]').first()).toBeVisible({ timeout: 15000 }); + await page.waitForTimeout(1000); } export async function loginAsAdmin(page: Page) { @@ -112,13 +121,21 @@ export async function getVisiblePrototypeNames(page: Page): Promise { } export async function getAuthToken(page: Page): Promise { + return getTokenForUser(page, ADMIN.email, ADMIN.password); +} + +export async function getTokenForUser( + page: Page, + email: string, + password: string, +): Promise { const loginRes = await page.request.post(`${API_URL}/v2/auth/login`, { - data: { email: ADMIN.email, password: ADMIN.password }, + data: { email, password }, }); const loginData = await loginRes.json(); const token = loginData?.tokens?.access?.token; if (!token) { - throw new Error(`Failed to get auth token: ${loginRes.status()}`); + throw new Error(`Failed to get auth token for ${email}: ${loginRes.status()}`); } return token; } @@ -179,8 +196,11 @@ export async function createTestModelViaApi( page: Page, name: string, visibility: 'public' | 'private', + auth?: { email: string; password: string }, ): Promise { - const token = await getAuthToken(page); + const token = auth + ? await getTokenForUser(page, auth.email, auth.password) + : await getAuthToken(page); const res = await page.request.post(`${API_URL}/v2/models`, { data: { name, @@ -266,8 +286,7 @@ export async function setPrototypeStateViaUI( const editBtn = page.locator('[data-id="btn-edit-prototype-info"]'); await expect(editBtn).toBeVisible({ timeout: 15000 }); - await editBtn.scrollIntoViewIfNeeded(); - await editBtn.click(); + await editBtn.click({ force: true }); await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 15000 }); const statusTrigger = page.locator('[data-id="prototype-status-select"]'); @@ -291,7 +310,7 @@ export function getPopularSection(page: Page): Locator { .locator('xpath=ancestor::div[contains(@class,"container")][1]'); } -export async function boostPrototypePopularity(page: Page, prototypeId: string, turns = 10) { +export async function boostPrototypePopularity(page: Page, prototypeId: string, turns = 100) { const token = await getAuthToken(page); for (let i = 0; i < turns; i++) { const res = await page.request.post(`${API_URL}/v2/prototypes/${prototypeId}/execute-code`, { @@ -324,6 +343,30 @@ export async function waitForPrototypeTabs(page: Page, timeoutMs = 30000) { await expect(codeTab).toBeVisible({ timeout: 5000 }); } +export async function waitForPrototypeInPopularApi( + page: Page, + protoName: string, + timeoutMs = 60000, +): Promise { + const token = await getAuthToken(page); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const res = await page.request.get(`${API_URL}/v2/prototypes/popular`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok()) { + const data = await res.json(); + if (Array.isArray(data) && data.some((p: { name?: string }) => p.name === protoName)) { + return; + } + } + await page.waitForTimeout(1000); + } + + throw new Error(`Prototype "${protoName}" did not appear in popular API within ${timeoutMs}ms`); +} + export async function expectPrototypeInPopular(page: Page, name: string, visible: boolean) { await page.goto('/'); await page.waitForLoadState('domcontentloaded'); @@ -968,6 +1011,44 @@ export async function removeRoleFromUserViaApi(page: Page, userId: string, roleI } } +export async function addModelPermissionViaApi( + page: Page, + modelId: string, + userId: string, + role: 'model_contributor' | 'model_member', + auth?: { email: string; password: string }, +): Promise { + const token = auth + ? await getTokenForUser(page, auth.email, auth.password) + : await getAuthToken(page); + const res = await page.request.post(`${API_URL}/v2/models/${modelId}/permissions`, { + data: { role, userId }, + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) { + throw new Error(`Failed to add model permission: ${res.status()} ${await res.text()}`); + } +} + +export async function removeModelPermissionViaApi( + page: Page, + modelId: string, + userId: string, + role: 'model_contributor' | 'model_member', + auth?: { email: string; password: string }, +): Promise { + const token = auth + ? await getTokenForUser(page, auth.email, auth.password) + : await getAuthToken(page); + const res = await page.request.delete(`${API_URL}/v2/models/${modelId}/permissions`, { + params: { userId, role }, + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok() && res.status() !== 404) { + throw new Error(`Failed to remove model permission: ${res.status()} ${await res.text()}`); + } +} + export async function deleteAssetViaApi(page: Page, assetId: string) { const token = await getAuthToken(page); const res = await page.request.delete(`${API_URL}/v2/assets/${assetId}`, { @@ -990,12 +1071,531 @@ export async function findMyPluginByNameViaApi(page: Page, name: string) { return (data?.results || []).find((p: { name: string }) => p.name === name) || null; } +export async function configureHomePopularSection( + page: Page, + title = 'Popular Prototypes', +): Promise { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', [{ type: 'popular', title }]); +} + +export async function configureHomeModelListSection( + page: Page, + title = 'Vehicle Models', +): Promise { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', [{ type: 'model-list', title }]); +} + +export async function configureHomePrototypeListSection( + page: Page, + title = 'All Prototypes', +): Promise { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', [{ type: 'prototype-list', title }]); +} + +export function getHomePrototypeListSection(page: Page, title = 'All Prototypes'): Locator { + return page + .locator('.da-page-home') + .locator('h2', { hasText: title }) + .locator('xpath=ancestor::div[contains(@class,"container")][1]'); +} + +export async function gotoHomePrototypeList(page: Page, title = 'All Prototypes'): Promise { + const responsePromise = page + .waitForResponse( + (res) => + res.url().includes('/prototypes') && + res.request().method() === 'GET' && + res.ok(), + { timeout: 20000 }, + ) + .catch(() => null); + await page.goto('/'); + await expect(page.getByRole('heading', { name: title })).toBeVisible({ timeout: 20000 }); + await responsePromise; + await page.waitForTimeout(500); +} + +export async function selectHomePrototypeCategory( + page: Page, + label: 'All' | 'My Prototypes', +): Promise { + const section = getHomePrototypeListSection(page); + const responsePromise = page + .waitForResponse( + (res) => + res.url().includes('/prototypes') && + res.request().method() === 'GET' && + res.ok(), + { timeout: 20000 }, + ) + .catch(() => null); + await section.getByRole('button', { name: label, exact: true }).click(); + await responsePromise; + await page.waitForTimeout(500); +} + +export async function selectHomePrototypeSort( + page: Page, + option: string, + title = 'All Prototypes', +): Promise { + const section = getHomePrototypeListSection(page, title); + const sortButton = section.getByRole('button', { + name: /Newest|Oldest|Name A-Z|Name Z-A|Last Viewed|First Viewed/, + }); + await sortButton.click(); + await page + .locator('ul') + .locator('label') + .filter({ has: page.getByText(option, { exact: true }) }) + .click(); + await page.waitForTimeout(1000); +} + +export async function getVisibleHomePrototypeNames( + page: Page, + title = 'All Prototypes', +): Promise { + const section = getHomePrototypeListSection(page, title); + const names = await section.locator('.prototype-grid-item-name').allTextContents(); + return names.map((n) => n.trim()).filter(Boolean); +} + +export const HOME_PROTOTYPE_SORT_OPTIONS = [ + { label: 'Newest', param: 'newest' }, + { label: 'Oldest', param: 'oldest' }, + { label: 'Name A-Z', param: 'name-az' }, + { label: 'Name Z-A', param: 'name-za' }, + { label: 'Last Viewed', param: 'last-viewed' }, + { label: 'First Viewed', param: 'first-viewed' }, +] as const; + +export async function setPrototypeLastViewedMap( + page: Page, + entries: Record, +): Promise { + await page.evaluate((map) => { + localStorage.setItem('prototype_last_viewed', JSON.stringify(map)); + }, entries); +} + +export async function assertHomePrototypeOrder( + page: Page, + firstName: string, + secondName: string, + title = 'All Prototypes', +): Promise { + await ensureHomePrototypeVisible(page, firstName, title); + await ensureHomePrototypeVisible(page, secondName, title); + const names = await getVisibleHomePrototypeNames(page, title); + expect(names.indexOf(firstName)).toBeGreaterThanOrEqual(0); + expect(names.indexOf(secondName)).toBeGreaterThanOrEqual(0); + expect(names.indexOf(firstName)).toBeLessThan(names.indexOf(secondName)); +} + +export async function gotoHomeModelList(page: Page, title = 'Vehicle Models'): Promise { + const responsePromise = page + .waitForResponse( + (res) => + (res.url().includes('/models') || res.url().includes('/model')) && + res.request().method() === 'GET' && + res.ok(), + { timeout: 20000 }, + ) + .catch(() => null); + await page.goto('/'); + await expect(page.getByRole('heading', { name: title })).toBeVisible({ timeout: 20000 }); + await responsePromise; + await page.waitForTimeout(500); +} + +export function getHomeModelListSection(page: Page, title = 'Vehicle Models'): Locator { + return page + .locator('.da-page-home') + .locator('h2', { hasText: title }) + .locator('xpath=ancestor::div[contains(@class,"container")][1]'); +} + +export async function selectHomeModelCategory( + page: Page, + label: 'All' | 'My Models' | 'My Contributions' | 'Public', + title = 'Vehicle Models', +): Promise { + const section = getHomeModelListSection(page, title); + const desktopBtn = section.getByRole('button', { name: label, exact: true }); + if (await desktopBtn.isVisible().catch(() => false)) { + await desktopBtn.click(); + } else { + await section.getByRole('button').filter({ hasText: /All|My Models|My Contributions|Public/ }).first().click(); + await page.getByRole('menuitem', { name: label }).click(); + } + await page.waitForTimeout(1500); +} + +export async function selectHomeModelSort( + page: Page, + option: string, + title = 'Vehicle Models', +): Promise { + const section = getHomeModelListSection(page, title); + await section + .getByRole('button', { name: /Last viewed|First viewed|Newest|Oldest|Name A-Z|Name Z-A/ }) + .click(); + await page.getByRole('menuitem', { name: option }).click(); + await page.waitForTimeout(1500); +} + +export async function getVisibleHomeModelNames( + page: Page, + title = 'Vehicle Models', +): Promise { + const section = getHomeModelListSection(page, title); + const names = await section.locator('h3.font-semibold').allTextContents(); + return names.map((n) => n.trim()).filter(Boolean); +} + +export const HOME_MODEL_SORT_OPTIONS = [ + { label: 'Last viewed', param: 'last-viewed' }, + { label: 'First viewed', param: 'first-viewed' }, + { label: 'Newest', param: 'newest' }, + { label: 'Oldest', param: 'oldest' }, + { label: 'Name A-Z', param: 'name-az' }, + { label: 'Name Z-A', param: 'name-za' }, +] as const; + +export async function setModelLastViewedMap( + page: Page, + entries: Record, +): Promise { + await page.evaluate((map) => { + localStorage.setItem('model_last_viewed', JSON.stringify(map)); + }, entries); +} + +export async function assertHomeModelOrder( + page: Page, + firstName: string, + secondName: string, + title = 'Vehicle Models', +): Promise { + await ensureHomeModelVisible(page, firstName, title); + await ensureHomeModelVisible(page, secondName, title); + const names = await getVisibleHomeModelNames(page, title); + expect(names.indexOf(firstName)).toBeGreaterThanOrEqual(0); + expect(names.indexOf(secondName)).toBeGreaterThanOrEqual(0); + expect(names.indexOf(firstName)).toBeLessThan(names.indexOf(secondName)); +} + +export async function ensureHomeModelVisible( + page: Page, + modelName: string, + title = 'Vehicle Models', +): Promise { + const section = getHomeModelListSection(page, title); + const card = section.locator(`[aria-label="${modelName}"]`); + + for (let i = 0; i < 15; i++) { + if (await card.isVisible().catch(() => false)) { + return; + } + const loadMore = section.getByRole('button', { name: /Load More Models/i }); + if (!(await loadMore.isVisible().catch(() => false))) { + break; + } + await loadMore.click(); + await page.waitForTimeout(500); + } + + await expect(card).toBeVisible({ timeout: 10000 }); +} + +export async function ensureHomePrototypeVisible( + page: Page, + protoName: string, + title = 'All Prototypes', +): Promise { + const section = getHomePrototypeListSection(page, title); + const card = section.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first(); + + for (let i = 0; i < 20; i++) { + if (await card.isVisible().catch(() => false)) { + return; + } + const rightBtn = section.locator('div.relative > button').last(); + if (!(await rightBtn.isEnabled().catch(() => false))) { + break; + } + await rightBtn.click(); + await page.waitForTimeout(500); + } + + await expect(card).toBeVisible({ timeout: 10000 }); +} + +export async function waitForModelInHomeList( + page: Page, + modelName: string, + title = 'Vehicle Models', +): Promise { + await gotoHomeModelList(page, title); + await selectHomeModelCategory(page, 'My Models', title); + const section = getHomeModelListSection(page, title); + await expect(section.locator(`[aria-label="${modelName}"]`)).toBeVisible({ timeout: 20000 }); +} + +export async function waitForPrototypeInHomeList( + page: Page, + protoName: string, + title = 'All Prototypes', +): Promise { + await gotoHomePrototypeList(page, title); + await selectHomePrototypeCategory(page, 'My Prototypes'); + const section = getHomePrototypeListSection(page, title); + await expect( + section.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first(), + ).toBeVisible({ timeout: 20000 }); +} + +export async function renameModelViaHomeContextMenu( + page: Page, + oldName: string, + newName: string, + title = 'Vehicle Models', +): Promise { + const section = getHomeModelListSection(page, title); + await openModelContextMenu(page, oldName, section); + await page.getByRole('menuitem', { name: 'Rename' }).click(); + const dialog = page.getByRole('dialog').filter({ + has: page.getByRole('heading', { name: 'Rename Model' }), + }); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await dialog.locator('input').fill(newName); + await dialog.getByRole('button', { name: 'Save' }).click(); + await page.waitForTimeout(2000); +} + +export function getModelCard(page: Page, modelName: string, section?: Locator): Locator { + const root = section ?? page; + return root.locator(`[aria-label="${modelName}"]`).first(); +} + +export function getPrototypeCard(page: Page, protoName: string): Locator { + return page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first(); +} + +export async function openModelContextMenu( + page: Page, + modelName: string, + section?: Locator, +): Promise { + const card = getModelCard(page, modelName, section); + await expect(card).toBeVisible({ timeout: 20000 }); + await card.scrollIntoViewIfNeeded(); + await card.click({ button: 'right', force: true }); + await expect(page.getByRole('menuitem').first()).toBeVisible({ timeout: 10000 }); +} + +export async function openPrototypeContextMenu(page: Page, protoName: string): Promise { + const card = getPrototypeCard(page, protoName); + await expect(card).toBeVisible({ timeout: 20000 }); + await card.scrollIntoViewIfNeeded(); + await card.click({ button: 'right', force: true }); + await expect(page.getByRole('menuitem').first()).toBeVisible({ timeout: 10000 }); +} + +export async function downloadViaContextMenuItem( + page: Page, + menuLabel: string, +): Promise { + const downloadDir = join(tmpdir(), `autowrx-e2e-${Date.now()}`); + await mkdir(downloadDir, { recursive: true }); + + const [download] = await Promise.all([ + page.waitForEvent('download', { timeout: 60000 }), + page.getByRole('menuitem', { name: menuLabel }).click(), + ]); + + const suggestedName = download.suggestedFilename(); + const savePath = join(downloadDir, suggestedName); + await download.saveAs(savePath); + return savePath; +} + +export async function confirmNameDialog(page: Page, name: string): Promise { + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + const input = dialog.locator('input[data-slot="input"], input').first(); + await input.fill(name); + await dialog.getByRole('button', { name: 'Confirm' }).click(); + await page.waitForTimeout(2000); +} + +export async function deleteModelViaApi(page: Page, modelId: string): Promise { + const token = await getAuthToken(page); + const res = await page.request.delete(`${API_URL}/v2/models/${modelId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok() && res.status() !== 404) { + throw new Error(`Failed to delete model: ${res.status()} ${await res.text()}`); + } +} + +export async function deletePrototypeViaApi(page: Page, prototypeId: string): Promise { + const token = await getAuthToken(page); + const res = await page.request.delete(`${API_URL}/v2/prototypes/${prototypeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok() && res.status() !== 404) { + throw new Error(`Failed to delete prototype: ${res.status()} ${await res.text()}`); + } +} + +export async function importModelZipOnHome(page: Page, zipPath: string): Promise { + const fileChooserPromise = page.waitForEvent('filechooser'); + const section = getHomeModelListSection(page); + await section.getByRole('button', { name: /Import Model/i }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(zipPath); + await page.waitForURL(/\/model\/.+/, { timeout: 120000 }); +} + +export async function importPrototypeZip( + page: Page, + zipPath: string, + protoName?: string, + modelId?: string, +): Promise { + const importButton = page.getByRole('button', { name: /Import Prototype/i }).first(); + await expect(importButton).toBeVisible({ timeout: 15000 }); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await importButton.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(zipPath); + + const dialog = page.getByRole('dialog', { name: /Import Prototype/i }); + await expect(dialog).toBeVisible({ timeout: 30000 }); + await expect(dialog.getByRole('button', { name: 'Import' })).toBeEnabled({ timeout: 60000 }); + + if (protoName) { + const nameInput = dialog.locator('input').first(); + await expect(nameInput).toBeVisible({ timeout: 15000 }); + await nameInput.fill(protoName); + } + + await dialog.getByRole('button', { name: 'Import' }).click(); + await page.waitForURL(/\/library\/prototype\//, { timeout: 120000 }).catch(() => {}); + await expect(dialog).toBeHidden({ timeout: 120000 }); + + if (modelId) { + await page.goto(`/model/${modelId}/library/list`); + await page.waitForTimeout(2000); + } +} + +export async function removeTempDownloadDir(dirPath: string): Promise { + try { + await rm(dirPath, { recursive: true, force: true }); + } catch { + // Best-effort cleanup for temp download directories. + } +} + +export const DEFAULT_MODEL_IMAGE = '/imgs/default-model-image.png'; +export const DEFAULT_PROTOTYPE_IMAGE = '/imgs/default_prototype_cover.jpg'; + +export async function setModelImageViaApi( + page: Page, + modelId: string, + imageUrl: string, +): Promise { + const token = await getAuthToken(page); + const res = await page.request.patch(`${API_URL}/v2/models/${modelId}`, { + data: { model_home_image_file: imageUrl }, + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) { + throw new Error(`Failed to set model image: ${res.status()} ${await res.text()}`); + } +} + +export async function setPrototypeImageViaApi( + page: Page, + prototypeId: string, + imageUrl: string, +): Promise { + const token = await getAuthToken(page); + const res = await page.request.patch(`${API_URL}/v2/prototypes/${prototypeId}`, { + data: { image_file: imageUrl }, + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok()) { + throw new Error(`Failed to set prototype image: ${res.status()} ${await res.text()}`); + } +} + +export async function routeBrokenImageUrls( + page: Page, + pattern = 'e2e-broken-image', +): Promise<() => Promise> { + const handler = async (route: { request: () => { url: () => string }; abort: (errorCode?: string) => Promise; continue: () => Promise }) => { + if (route.request().url().includes(pattern)) { + await route.abort('failed'); + return; + } + await route.continue(); + }; + await page.route('**/*', handler); + return () => page.unroute('**/*', handler); +} + +export async function expectCardImageFallback( + card: Locator, + expectedFallbackSrc: string, +): Promise { + const img = card.locator('img').first(); + await expect(img).toHaveAttribute('data-fallback-applied', 'true', { timeout: 15000 }); + await expect(img).toHaveAttribute( + 'src', + new RegExp(expectedFallbackSrc.replace(/\//g, '\\/')), + ); +} + export async function checkLayoutAnomalies(page: Page, testName: string) { const overlapping = await page.evaluate(() => { const panel = document.querySelector('[data-id="runtime-control-panel"]'); const panelRect = panel?.getBoundingClientRect(); const isPanelCollapsed = panelRect ? panelRect.width <= 60 : false; + const isEffectivelyHidden = (el: Element): boolean => { + if (el.closest('[aria-hidden="true"]')) return true; + + let node: Element | null = el; + while (node) { + const style = window.getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return true; + } + if ( + node !== el && + node.getAttribute('data-state') === 'closed' && + ['menu', 'dialog', 'listbox', 'tooltip'].includes(node.getAttribute('role') || '') + ) { + return true; + } + node = node.parentElement; + } + + const rects = el.getClientRects(); + if (rects.length === 0) return true; + + let area = 0; + for (const rect of rects) { + area += rect.width * rect.height; + } + return area === 0; + }; + const elements = document.querySelectorAll('button, a, input:not([type="hidden"])'); const issues: string[] = []; elements.forEach((el) => { @@ -1003,13 +1603,12 @@ export async function checkLayoutAnomalies(page: Page, testName: string) { if (input.type === 'hidden') return; if (isPanelCollapsed && panel?.contains(el)) return; - - const style = window.getComputedStyle(el); - if (style.display === 'none' || style.visibility === 'hidden') return; + if (isEffectivelyHidden(el)) return; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) { - issues.push(`Zero-size visible element: ${el.tagName} "${el.textContent?.slice(0, 30)}"`); + const label = el.getAttribute('aria-label') || el.textContent?.trim().slice(0, 30) || ''; + issues.push(`Zero-size visible element: ${el.tagName} "${label}"`); } }); return issues; diff --git a/.agents/tests/home-model-list.spec.ts b/.agents/tests/home-model-list.spec.ts new file mode 100644 index 00000000..ecbaf8ff --- /dev/null +++ b/.agents/tests/home-model-list.spec.ts @@ -0,0 +1,486 @@ +import { test, expect } from '@playwright/test'; +import { + loginAsAdmin, + saveScreenshot, + createPublicReleasedModelViaApi, + createTestModelViaApi, + createTestUserViaApi, + deleteTestUserViaApi, + deleteModelViaApi, + configureHomeModelListSection, + getSiteConfigJson, + setSiteConfigJson, + gotoHomeModelList, + selectHomeModelCategory, + selectHomeModelSort, + renameModelViaHomeContextMenu, + getHomeModelListSection, + assertHomeModelOrder, + setModelLastViewedMap, + waitForModelInHomeList, + loginAs, + logout, + TEST_USER, + addModelPermissionViaApi, +} from './helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Home Model List', () => { + let originalHomeContent: unknown = null; + const createdModelIds: string[] = []; + let testUserId: string | null = null; + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await configureHomeModelListSection(page); + }); + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + originalHomeContent = await getSiteConfigJson(page, 'CFG_HOME_CONTENT'); + await configureHomeModelListSection(page); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + if (originalHomeContent !== null) { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', originalHomeContent); + } + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + while (createdModelIds.length > 0) { + const modelId = createdModelIds.pop(); + if (modelId) { + await deleteModelViaApi(page, modelId).catch(() => {}); + } + } + if (testUserId) { + await deleteTestUserViaApi(page, testUserId).catch(() => {}); + testUserId = null; + } + await page.close(); + }); + + test('guest home model-list shows only public released models', async ({ browser }) => { + const timestamp = Date.now(); + const publicName = `E2E_HomePublic_${timestamp}`; + const privateName = `E2E_HomePrivate_${timestamp}`; + + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + await loginAsAdmin(adminPage); + + createdModelIds.push(await createPublicReleasedModelViaApi(adminPage, publicName)); + createdModelIds.push(await createTestModelViaApi(adminPage, privateName, 'private')); + await adminContext.close(); + + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + await gotoHomeModelList(guestPage); + + const section = getHomeModelListSection(guestPage); + await expect(section.getByText(publicName)).toBeVisible({ timeout: 20000 }); + await expect(section.getByText(privateName)).toHaveCount(0, { timeout: 10000 }); + + await saveScreenshot(guestPage, 'home-model-list-guest-public-only'); + await guestContext.close(); + }); + + test('logged-in admin sees category tabs and model actions', async ({ browser }) => { + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + await loginAsAdmin(adminPage); + await gotoHomeModelList(adminPage); + + const section = getHomeModelListSection(adminPage); + await expect(section.getByRole('button', { name: 'All', exact: true })).toBeVisible({ + timeout: 10000, + }); + await expect(section.getByRole('button', { name: 'My Models', exact: true })).toBeVisible(); + await expect( + section.getByRole('button', { name: 'My Contributions', exact: true }), + ).toBeVisible(); + await expect(section.getByRole('button', { name: 'Public', exact: true })).toBeVisible(); + await expect(section.getByRole('button', { name: /Import Model/i })).toBeVisible(); + await expect(section.getByRole('button', { name: /Add Model/i })).toBeVisible(); + + await saveScreenshot(adminPage, 'home-model-list-logged-in-ui'); + await adminContext.close(); + + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + await gotoHomeModelList(guestPage); + + const guestSection = getHomeModelListSection(guestPage); + await expect(guestSection.getByRole('button', { name: 'My Models', exact: true })).toHaveCount(0); + await expect(guestSection.getByRole('button', { name: /Import Model/i })).toHaveCount(0); + await expect(guestSection.getByRole('button', { name: /Add Model/i })).toHaveCount(0); + + await guestContext.close(); + }); + + test('My Models category shows owned private model', async ({ page }) => { + const timestamp = Date.now(); + const privateName = `AAA_HomeMyModel_${timestamp}`; + const otherUserModelName = `AAA_HomeMyModelOther_${timestamp}`; + + await loginAsAdmin(page); + createdModelIds.push(await createTestModelViaApi(page, privateName, 'private')); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_home_my_model_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E User ${timestamp}`, + }); + testUserId = testUser.id; + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + createdModelIds.push( + await createTestModelViaApi(page, otherUserModelName, 'private', { + email: testUser.email, + password: TEST_USER.password, + }), + ); + + await logout(page); + await loginAsAdmin(page); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Name A-Z'); + + expect(page.url()).toContain('model-category=myModel'); + const section = getHomeModelListSection(page); + await expect(section.locator(`[aria-label="${privateName}"]`)).toBeVisible({ timeout: 20000 }); + await expect(section.locator(`[aria-label="${otherUserModelName}"]`)).toHaveCount(0, { + timeout: 10000, + }); + + await saveScreenshot(page, 'home-model-list-my-models-filter'); + }); + + test('Public category shows only public released models', async ({ page }) => { + const timestamp = Date.now(); + const privateName = `E2E_HomePublicFilterPrivate_${timestamp}`; + const publicName = `E2E_HomePublicFilterPublic_${timestamp}`; + + await loginAsAdmin(page); + createdModelIds.push(await createTestModelViaApi(page, privateName, 'private')); + createdModelIds.push(await createPublicReleasedModelViaApi(page, publicName)); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'Public'); + + expect(page.url()).toContain('model-category=public'); + const section = getHomeModelListSection(page); + await expect(section.getByText(publicName)).toBeVisible({ timeout: 20000 }); + await expect(section.getByText(privateName)).toHaveCount(0, { timeout: 10000 }); + + await saveScreenshot(page, 'home-model-list-public-filter'); + }); + + test.describe('sort options', () => { + test('sort by Newest orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const firstName = `First_HomeModelSort_${timestamp}`; + const secondName = `Second_HomeModelSort_${timestamp}`; + + createdModelIds.push(await createTestModelViaApi(page, firstName, 'private')); + await page.waitForTimeout(1000); + createdModelIds.push(await createTestModelViaApi(page, secondName, 'private')); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Newest'); + + expect(page.url()).toContain('model-sort=newest'); + await assertHomeModelOrder(page, secondName, firstName); + await saveScreenshot(page, 'home-model-list-sort-newest'); + }); + + test('sort by Oldest orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const firstName = `First_HomeModelSort_${timestamp}`; + const secondName = `Second_HomeModelSort_${timestamp}`; + + createdModelIds.push(await createTestModelViaApi(page, firstName, 'private')); + await page.waitForTimeout(1000); + createdModelIds.push(await createTestModelViaApi(page, secondName, 'private')); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Oldest'); + + expect(page.url()).toContain('model-sort=oldest'); + await assertHomeModelOrder(page, firstName, secondName); + await saveScreenshot(page, 'home-model-list-sort-oldest'); + }); + + test('sort by Name A-Z orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const nameA = `AAA_HomeModelSort_${timestamp}`; + const nameZ = `ZZZ_HomeModelSort_${timestamp}`; + + createdModelIds.push(await createTestModelViaApi(page, nameZ, 'private')); + createdModelIds.push(await createTestModelViaApi(page, nameA, 'private')); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Name A-Z'); + + expect(page.url()).toContain('model-sort=name-az'); + await assertHomeModelOrder(page, nameA, nameZ); + await saveScreenshot(page, 'home-model-list-sort-az'); + }); + + test('sort by Name Z-A orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const nameA = `AAA_HomeModelSort_${timestamp}`; + const nameZ = `ZZZ_HomeModelSort_${timestamp}`; + + createdModelIds.push(await createTestModelViaApi(page, nameZ, 'private')); + createdModelIds.push(await createTestModelViaApi(page, nameA, 'private')); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Name Z-A'); + + expect(page.url()).toContain('model-sort=name-za'); + await assertHomeModelOrder(page, nameZ, nameA); + await saveScreenshot(page, 'home-model-list-sort-za'); + }); + + test('sort by Last viewed orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const modelAName = `AAA_HomeModelView_${timestamp}`; + const modelBName = `ZZZ_HomeModelView_${timestamp}`; + + const modelAId = await createTestModelViaApi(page, modelAName, 'private'); + createdModelIds.push(modelAId); + const modelBId = await createTestModelViaApi(page, modelBName, 'private'); + createdModelIds.push(modelBId); + + await setModelLastViewedMap(page, { + [modelAId]: 1000, + [modelBId]: 2000, + }); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'Name A-Z'); + expect(page.url()).toContain('model-sort=name-az'); + await selectHomeModelSort(page, 'Last viewed'); + expect(page.url()).not.toContain('model-sort='); + await assertHomeModelOrder(page, modelBName, modelAName); + await saveScreenshot(page, 'home-model-list-sort-last-viewed'); + }); + + test('sort by First viewed orders model cards', async ({ page }) => { + const timestamp = Date.now(); + const modelAName = `AAA_HomeModelView_${timestamp}`; + const modelBName = `ZZZ_HomeModelView_${timestamp}`; + + const modelAId = await createTestModelViaApi(page, modelAName, 'private'); + createdModelIds.push(modelAId); + const modelBId = await createTestModelViaApi(page, modelBName, 'private'); + createdModelIds.push(modelBId); + + await setModelLastViewedMap(page, { + [modelAId]: 1000, + [modelBId]: 2000, + }); + + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + await selectHomeModelSort(page, 'First viewed'); + + expect(page.url()).toContain('model-sort=first-viewed'); + await assertHomeModelOrder(page, modelAName, modelBName); + await saveScreenshot(page, 'home-model-list-sort-first-viewed'); + }); + }); + + test('rename model via home context menu', async ({ page }) => { + const timestamp = Date.now(); + const originalName = `E2E_HomeRename_${timestamp}`; + const renamedName = `${originalName}_renamed`; + + await loginAsAdmin(page); + createdModelIds.push(await createTestModelViaApi(page, originalName, 'private')); + + await waitForModelInHomeList(page, originalName); + + await renameModelViaHomeContextMenu(page, originalName, renamedName); + + const section = getHomeModelListSection(page); + await selectHomeModelCategory(page, 'My Models'); + await expect(section.locator(`[aria-label="${renamedName}"]`)).toBeVisible({ timeout: 20000 }); + await expect(section.locator(`[aria-label="${originalName}"]`)).toHaveCount(0, { + timeout: 10000, + }); + + await saveScreenshot(page, 'home-model-list-rename'); + }); + + test('My Contributions shows model for contributor', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ContribModel_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_contrib_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E Contributor ${timestamp}`, + }); + testUserId = testUser.id; + await addModelPermissionViaApi(page, modelId, testUser.id, 'model_contributor'); + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Contributions'); + + expect(page.url()).toContain('model-category=myContribution'); + const section = getHomeModelListSection(page); + await expect(section.locator(`[aria-label="${modelName}"]`)).toBeVisible({ timeout: 20000 }); + + await saveScreenshot(page, 'home-model-list-contributor-visible'); + }); + + test('contributed model is hidden from My Models tab', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ContribMyModels_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_contrib_my_models_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E Contributor ${timestamp}`, + }); + testUserId = testUser.id; + await addModelPermissionViaApi(page, modelId, testUser.id, 'model_contributor'); + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + + expect(page.url()).toContain('model-category=myModel'); + const section = getHomeModelListSection(page); + await expect(section.locator(`[aria-label="${modelName}"]`)).toHaveCount(0, { + timeout: 10000, + }); + + await saveScreenshot(page, 'home-model-list-contributor-hidden-my-models'); + }); + + test('contributed private model is hidden from Public tab', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ContribPublic_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_contrib_public_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E Contributor ${timestamp}`, + }); + testUserId = testUser.id; + await addModelPermissionViaApi(page, modelId, testUser.id, 'model_contributor'); + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'Public'); + + expect(page.url()).toContain('model-category=public'); + const section = getHomeModelListSection(page); + await expect(section.locator(`[aria-label="${modelName}"]`)).toHaveCount(0, { + timeout: 10000, + }); + + await saveScreenshot(page, 'home-model-list-contributor-hidden-public'); + }); + + test('contributor can open new prototype from home card', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ContribProto_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_contrib_proto_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E Contributor ${timestamp}`, + }); + testUserId = testUser.id; + await addModelPermissionViaApi(page, modelId, testUser.id, 'model_contributor'); + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Contributions'); + + const section = getHomeModelListSection(page); + const card = section.locator(`[aria-label="${modelName}"]`); + await expect(card).toBeVisible({ timeout: 20000 }); + await card.getByTitle('Add new Prototype').click(); + + await page.waitForURL(/\/new-prototype\?model_id=/, { timeout: 30000 }); + expect(new URL(page.url()).searchParams.get('model_id')).toBe(modelId); + + await saveScreenshot(page, 'home-model-list-contributor-new-prototype'); + }); + + test('reader sees contributed model but edit menu is disabled', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ContribReader_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_contrib_reader_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E Reader ${timestamp}`, + }); + testUserId = testUser.id; + await addModelPermissionViaApi(page, modelId, testUser.id, 'model_member'); + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Contributions'); + + const section = getHomeModelListSection(page); + const card = section.locator(`[aria-label="${modelName}"]`); + await expect(card).toBeVisible({ timeout: 20000 }); + await expect(card.getByTitle('Menu')).toBeDisabled(); + + await card.click({ button: 'right', force: true }); + await expect(page.getByText('Permission denied', { exact: true })).toBeVisible({ + timeout: 10000, + }); + + await saveScreenshot(page, 'home-model-list-reader-no-edit'); + }); +}); diff --git a/.agents/tests/home-prototype-list.spec.ts b/.agents/tests/home-prototype-list.spec.ts new file mode 100644 index 00000000..3fdf34bf --- /dev/null +++ b/.agents/tests/home-prototype-list.spec.ts @@ -0,0 +1,349 @@ +import { test, expect } from '@playwright/test'; +import { + loginAsAdmin, + loginAs, + logout, + saveScreenshot, + createPublicReleasedModelViaApi, + createTestModelViaApi, + createTestPrototype, + createTestUserViaApi, + deleteModelViaApi, + deleteTestUserViaApi, + setPrototypeStateViaApi, + configureHomePrototypeListSection, + getSiteConfigJson, + setSiteConfigJson, + gotoHomePrototypeList, + selectHomePrototypeCategory, + selectHomePrototypeSort, + getVisibleHomePrototypeNames, + getHomePrototypeListSection, + assertHomePrototypeOrder, + setPrototypeLastViewedMap, + TEST_USER, + waitForPrototypeInHomeList, + ensureHomePrototypeVisible, +} from './helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Home Prototype List', () => { + let originalHomeContent: unknown = null; + const createdModelIds: string[] = []; + let testUserId: string | null = null; + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await configureHomePrototypeListSection(page); + }); + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + originalHomeContent = await getSiteConfigJson(page, 'CFG_HOME_CONTENT'); + await configureHomePrototypeListSection(page); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + if (originalHomeContent !== null) { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', originalHomeContent); + } + await page.close(); + }); + + test.afterEach(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + while (createdModelIds.length > 0) { + const modelId = createdModelIds.pop(); + if (modelId) { + await deleteModelViaApi(page, modelId).catch(() => {}); + } + } + if (testUserId) { + await deleteTestUserViaApi(page, testUserId).catch(() => {}); + testUserId = null; + } + await page.close(); + }); + + test('guest home prototype-list shows public released prototypes only', async ({ browser }) => { + const timestamp = Date.now(); + const publicProtoName = `AAA_HomeProtoPublic_${timestamp}`; + const privateProtoName = `AAA_HomeProtoPrivate_${timestamp}`; + + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + await loginAsAdmin(adminPage); + + const publicModelId = await createPublicReleasedModelViaApi( + adminPage, + `E2E_HomeProtoPublicModel_${timestamp}`, + ); + createdModelIds.push(publicModelId); + const { prototypeId: publicPrototypeId } = await createTestPrototype( + adminPage, + publicProtoName, + publicModelId, + ); + await setPrototypeStateViaApi(adminPage, publicPrototypeId, 'Released'); + + const privateModelId = await createTestModelViaApi( + adminPage, + `E2E_HomeProtoPrivateModel_${timestamp}`, + 'private', + ); + createdModelIds.push(privateModelId); + const { prototypeId: privatePrototypeId } = await createTestPrototype( + adminPage, + privateProtoName, + privateModelId, + ); + await setPrototypeStateViaApi(adminPage, privatePrototypeId, 'Released'); + await configureHomePrototypeListSection(adminPage); + await adminContext.close(); + + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + await gotoHomePrototypeList(guestPage); + await selectHomePrototypeSort(guestPage, 'Name A-Z'); + + const section = getHomePrototypeListSection(guestPage); + await expect( + section.locator(`[data-id^="prototype-item-"]:has-text("${publicProtoName}")`).first(), + ).toBeVisible({ timeout: 20000 }); + await expect( + section.locator(`[data-id^="prototype-item-"]:has-text("${privateProtoName}")`), + ).toHaveCount(0, { timeout: 10000 }); + + await saveScreenshot(guestPage, 'home-prototype-list-guest-public-only'); + await guestContext.close(); + }); + + test('logged-in admin sees category tabs', async ({ browser }) => { + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + await loginAsAdmin(adminPage); + await gotoHomePrototypeList(adminPage); + + const section = getHomePrototypeListSection(adminPage); + await expect(section.getByRole('button', { name: 'All', exact: true })).toBeVisible({ + timeout: 10000, + }); + await expect(section.getByRole('button', { name: 'My Prototypes', exact: true })).toBeVisible(); + + await saveScreenshot(adminPage, 'home-prototype-list-logged-in-ui'); + await adminContext.close(); + + const guestContext = await browser.newContext(); + const guestPage = await guestContext.newPage(); + await gotoHomePrototypeList(guestPage); + + const guestSection = getHomePrototypeListSection(guestPage); + await expect(guestSection.getByRole('button', { name: 'All', exact: true })).toHaveCount(0); + await expect(guestSection.getByRole('button', { name: 'My Prototypes', exact: true })).toHaveCount( + 0, + ); + + await guestContext.close(); + }); + + test('My Prototypes category shows only owned prototypes', async ({ page }) => { + const timestamp = Date.now(); + const adminProtoName = `E2E_HomeMyProtoAdmin_${timestamp}`; + const userProtoName = `E2E_HomeMyProtoUser_${timestamp}`; + + await loginAsAdmin(page); + const adminModelId = await createTestModelViaApi(page, `E2E_HomeMyProtoAdminModel_${timestamp}`, 'private'); + createdModelIds.push(adminModelId); + await createTestPrototype(page, adminProtoName, adminModelId); + + const testUser = await createTestUserViaApi(page, { + email: `e2e_home_my_proto_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E User ${timestamp}`, + }); + testUserId = testUser.id; + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + const userModelId = await createTestModelViaApi( + page, + `E2E_HomeMyProtoUserModel_${timestamp}`, + 'private', + { email: testUser.email, password: TEST_USER.password }, + ); + createdModelIds.push(userModelId); + await createTestPrototype(page, userProtoName, userModelId); + + await logout(page); + await loginAsAdmin(page); + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + + expect(page.url()).toContain('prototype-category=mine'); + + const section = getHomePrototypeListSection(page); + await expect( + section.locator(`[data-id^="prototype-item-"]:has-text("${adminProtoName}")`).first(), + ).toBeVisible({ timeout: 20000 }); + await expect( + section.locator(`[data-id^="prototype-item-"]:has-text("${userProtoName}")`), + ).toHaveCount(0, { timeout: 10000 }); + + await saveScreenshot(page, 'home-prototype-list-my-prototypes-filter'); + }); + + test.describe('sort options', () => { + test('sort by Newest orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const firstName = `First_HomeProtoSort_${timestamp}`; + const secondName = `Second_HomeProtoSort_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoSortModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, firstName, modelId); + await createTestPrototype(page, secondName, modelId); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + // Newest is the default sort — URL omits prototype-sort when unchanged. + // Switch away and back so the sort control actually applies. + await selectHomePrototypeSort(page, 'Oldest'); + expect(page.url()).toContain('prototype-sort=oldest'); + await selectHomePrototypeSort(page, 'Newest'); + expect(page.url()).not.toContain('prototype-sort='); + await assertHomePrototypeOrder(page, secondName, firstName); + await saveScreenshot(page, 'home-prototype-list-sort-newest'); + }); + + test('sort by Oldest orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const firstName = `First_HomeProtoSort_${timestamp}`; + const secondName = `Second_HomeProtoSort_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoSortModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, firstName, modelId); + await createTestPrototype(page, secondName, modelId); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + await selectHomePrototypeSort(page, 'Oldest'); + + expect(page.url()).toContain('prototype-sort=oldest'); + await assertHomePrototypeOrder(page, firstName, secondName); + await saveScreenshot(page, 'home-prototype-list-sort-oldest'); + }); + + test('sort by Name A-Z orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const nameA = `AAA_HomeProtoSort_${timestamp}`; + const nameZ = `ZZZ_HomeProtoSort_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoSortModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, nameZ, modelId); + await createTestPrototype(page, nameA, modelId); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + await selectHomePrototypeSort(page, 'Name A-Z'); + + expect(page.url()).toContain('prototype-sort=name-az'); + await assertHomePrototypeOrder(page, nameA, nameZ); + await saveScreenshot(page, 'home-prototype-list-sort-az'); + }); + + test('sort by Name Z-A orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const nameA = `AAA_HomeProtoSort_${timestamp}`; + const nameZ = `ZZZ_HomeProtoSort_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoSortModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, nameZ, modelId); + await createTestPrototype(page, nameA, modelId); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + await selectHomePrototypeSort(page, 'Name Z-A'); + + expect(page.url()).toContain('prototype-sort=name-za'); + await assertHomePrototypeOrder(page, nameZ, nameA); + await saveScreenshot(page, 'home-prototype-list-sort-za'); + }); + + test('sort by Last Viewed orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const protoAName = `AAA_HomeProtoView_${timestamp}`; + const protoBName = `ZZZ_HomeProtoView_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoViewModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + const { prototypeId: protoAId } = await createTestPrototype(page, protoAName, modelId); + const { prototypeId: protoBId } = await createTestPrototype(page, protoBName, modelId); + + await setPrototypeLastViewedMap(page, { + [protoAId]: 1000, + [protoBId]: 2000, + }); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + await selectHomePrototypeSort(page, 'Last Viewed'); + + expect(page.url()).toContain('prototype-sort=last-viewed'); + await assertHomePrototypeOrder(page, protoBName, protoAName); + await saveScreenshot(page, 'home-prototype-list-sort-last-viewed'); + }); + + test('sort by First Viewed orders prototype cards', async ({ page }) => { + const timestamp = Date.now(); + const protoAName = `AAA_HomeProtoView_${timestamp}`; + const protoBName = `ZZZ_HomeProtoView_${timestamp}`; + + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoViewModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + const { prototypeId: protoAId } = await createTestPrototype(page, protoAName, modelId); + const { prototypeId: protoBId } = await createTestPrototype(page, protoBName, modelId); + + await setPrototypeLastViewedMap(page, { + [protoAId]: 1000, + [protoBId]: 2000, + }); + + await gotoHomePrototypeList(page); + await selectHomePrototypeCategory(page, 'My Prototypes'); + await selectHomePrototypeSort(page, 'First Viewed'); + + expect(page.url()).toContain('prototype-sort=first-viewed'); + await assertHomePrototypeOrder(page, protoAName, protoBName); + await saveScreenshot(page, 'home-prototype-list-sort-first-viewed'); + }); + }); + + test('clicking prototype card navigates to detail', async ({ page }) => { + const timestamp = Date.now(); + const protoName = `E2E_HomeProtoClick_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, `E2E_HomeProtoClickModel_${timestamp}`, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, protoName, modelId); + + await waitForPrototypeInHomeList(page, protoName); + + const section = getHomePrototypeListSection(page); + await section.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first().click(); + await page.waitForURL(/\/library\/prototype\/.+\/view/, { timeout: 30000 }); + + expect(page.url()).toMatch(/\/library\/prototype\/.+\/view/); + + await saveScreenshot(page, 'home-prototype-list-click-navigate'); + }); +}); diff --git a/.agents/tests/home-prototypes.spec.ts b/.agents/tests/home-prototypes.spec.ts index 56a3302b..801e98a9 100644 --- a/.agents/tests/home-prototypes.spec.ts +++ b/.agents/tests/home-prototypes.spec.ts @@ -1,17 +1,42 @@ -import { test } from '@playwright/test'; +import { test, expect } from '@playwright/test'; import { loginAsAdmin, saveScreenshot, createTestModelViaApi, createTestPrototype, - setPrototypeStateViaUI, + setPrototypeStateViaApi, boostPrototypePopularity, expectPrototypeInPopular, + configureHomePopularSection, + waitForPrototypeInPopularApi, + getSiteConfigJson, + setSiteConfigJson, } from './helpers'; +test.describe.configure({ mode: 'serial' }); + test.describe('Home Popular Prototypes', () => { + let originalHomeContent: unknown = null; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + originalHomeContent = await getSiteConfigJson(page, 'CFG_HOME_CONTENT'); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + if (originalHomeContent !== null) { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', originalHomeContent); + } + await page.close(); + }); + test.beforeEach(async ({ page }) => { await loginAsAdmin(page); + await configureHomePopularSection(page); }); test('popular prototype appears when model is public and state is Released', async ({ page }) => { @@ -21,8 +46,9 @@ test.describe('Home Popular Prototypes', () => { const modelId = await createTestModelViaApi(page, modelName, 'public'); const { prototypeId } = await createTestPrototype(page, protoName, modelId); - await setPrototypeStateViaUI(page, 'Released', modelId, prototypeId); + await setPrototypeStateViaApi(page, prototypeId, 'Released'); await boostPrototypePopularity(page, prototypeId); + await waitForPrototypeInPopularApi(page, protoName); await expectPrototypeInPopular(page, protoName, true); await saveScreenshot(page, 'home-popular-visible'); @@ -35,7 +61,7 @@ test.describe('Home Popular Prototypes', () => { const modelId = await createTestModelViaApi(page, modelName, 'private'); const { prototypeId } = await createTestPrototype(page, protoName, modelId); - await setPrototypeStateViaUI(page, 'Released', modelId, prototypeId); + await setPrototypeStateViaApi(page, prototypeId, 'Released'); await expectPrototypeInPopular(page, protoName, false); await saveScreenshot(page, 'home-popular-private-hidden'); diff --git a/.agents/tests/home-sections.spec.ts b/.agents/tests/home-sections.spec.ts index 2f5cb8bb..5d6d7d45 100644 --- a/.agents/tests/home-sections.spec.ts +++ b/.agents/tests/home-sections.spec.ts @@ -7,6 +7,8 @@ import { setSiteConfigJson, } from './helpers'; +test.describe.configure({ mode: 'serial' }); + test.describe('Home Page Sections', () => { let originalHomeContent: unknown = null; diff --git a/.agents/tests/image-fallback.spec.ts b/.agents/tests/image-fallback.spec.ts new file mode 100644 index 00000000..f154b88f --- /dev/null +++ b/.agents/tests/image-fallback.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; +import { + loginAsAdmin, + saveScreenshot, + createTestModelViaApi, + createTestPrototype, + deleteModelViaApi, + configureHomeModelListSection, + gotoHomeModelList, + selectHomeModelCategory, + getHomeModelListSection, + getPrototypeCard, + searchPrototypeLibrary, + setModelImageViaApi, + setPrototypeImageViaApi, + routeBrokenImageUrls, + expectCardImageFallback, + DEFAULT_MODEL_IMAGE, + DEFAULT_PROTOTYPE_IMAGE, +} from './helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Image Fallback', () => { + const createdModelIds: string[] = []; + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await configureHomeModelListSection(page); + }); + + test.afterEach(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + while (createdModelIds.length > 0) { + const modelId = createdModelIds.pop(); + if (modelId) { + await deleteModelViaApi(page, modelId).catch(() => {}); + } + } + await page.close(); + }); + + test('model card falls back to default image on load failure', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ModelImgFallback_${timestamp}`; + const brokenUrl = `${process.env.BASE_URL}/e2e-broken-image-${timestamp}.png`; + + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + await setModelImageViaApi(page, modelId, brokenUrl); + + const unroute = await routeBrokenImageUrls(page); + try { + await gotoHomeModelList(page); + await selectHomeModelCategory(page, 'My Models'); + + const section = getHomeModelListSection(page); + const card = section.locator(`[aria-label="${modelName}"]`); + await expect(card).toBeVisible({ timeout: 20000 }); + await expectCardImageFallback(card, DEFAULT_MODEL_IMAGE); + + await saveScreenshot(page, 'image-fallback-model-card'); + } finally { + await unroute(); + } + }); + + test('prototype card falls back to default image on load failure', async ({ page }) => { + const timestamp = Date.now(); + const protoName = `E2E_ProtoImgFallback_${timestamp}`; + const brokenUrl = `${process.env.BASE_URL}/e2e-broken-image-${timestamp}.png`; + + const modelId = await createTestModelViaApi(page, `E2E_ModelForProtoImg_${timestamp}`, 'private'); + createdModelIds.push(modelId); + const { prototypeId } = await createTestPrototype(page, protoName, modelId); + await setPrototypeImageViaApi(page, prototypeId, brokenUrl); + + const unroute = await routeBrokenImageUrls(page); + try { + await page.goto(`/model/${modelId}/library/list`); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + + const card = getPrototypeCard(page, protoName); + await expect(card).toBeVisible({ timeout: 20000 }); + await expectCardImageFallback(card, DEFAULT_PROTOTYPE_IMAGE); + + await saveScreenshot(page, 'image-fallback-prototype-card'); + } finally { + await unroute(); + } + }); +}); diff --git a/.agents/tests/import-export.spec.ts b/.agents/tests/import-export.spec.ts new file mode 100644 index 00000000..1ef14f53 --- /dev/null +++ b/.agents/tests/import-export.spec.ts @@ -0,0 +1,144 @@ +import { dirname } from 'path'; +import { test, expect } from '@playwright/test'; +import { + loginAsAdmin, + saveScreenshot, + createTestModelViaApi, + createTestPrototype, + configureHomeModelListSection, + openModelContextMenu, + openPrototypeContextMenu, + downloadViaContextMenuItem, + deleteModelViaApi, + deletePrototypeViaApi, + importModelZipOnHome, + importPrototypeZip, + removeTempDownloadDir, + searchPrototypeLibrary, + getSiteConfigJson, + setSiteConfigJson, + waitForModelInHomeList, + getHomeModelListSection, +} from './helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Import / Export - Model & Prototype', () => { + test.setTimeout(120000); + + let originalHomeContent: unknown = null; + const createdModelIds: string[] = []; + + test.beforeAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + originalHomeContent = await getSiteConfigJson(page, 'CFG_HOME_CONTENT'); + await configureHomeModelListSection(page); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + const page = await browser.newPage(); + await loginAsAdmin(page); + if (originalHomeContent !== null) { + await setSiteConfigJson(page, 'CFG_HOME_CONTENT', originalHomeContent); + } + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAsAdmin(page); + await configureHomeModelListSection(page); + }); + + test.afterEach(async ({ page }) => { + await loginAsAdmin(page); + while (createdModelIds.length > 0) { + const modelId = createdModelIds.pop(); + if (modelId) { + await deleteModelViaApi(page, modelId).catch(() => {}); + } + } + }); + + test('export and import model round-trip via home page', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ExportModel_${timestamp}`; + const protoName = `E2E_ExportProto_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + await createTestPrototype(page, protoName, modelId); + + await waitForModelInHomeList(page, modelName); + + const section = getHomeModelListSection(page); + await openModelContextMenu(page, modelName, section); + const zipPath = await downloadViaContextMenuItem(page, 'Export Model'); + expect(zipPath).toMatch(/model_.*\.zip$/i); + + await deleteModelViaApi(page, modelId); + createdModelIds.pop(); + + await page.goto('/'); + await page.waitForTimeout(3000); + await importModelZipOnHome(page, zipPath); + await removeTempDownloadDir(dirname(zipPath)); + + await expect(page.getByRole('heading', { name: modelName })).toBeVisible({ timeout: 20000 }); + const importedModelUrl = page.url(); + expect(importedModelUrl).toMatch(/\/model\/.+/); + + const importedModelId = importedModelUrl.split('/model/')[1]?.split('/')[0]; + if (importedModelId) { + createdModelIds.push(importedModelId); + } + + await page.goto(`/model/${importedModelId}/library/list`); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + await expect(page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first()).toBeVisible({ + timeout: 30000, + }); + + await saveScreenshot(page, 'import-export-model-roundtrip'); + }); + + test('export and import prototype round-trip via library page', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_ProtoExportModel_${timestamp}`; + const protoName = `E2E_ProtoExport_${timestamp}`; + + await loginAsAdmin(page); + const modelId = await createTestModelViaApi(page, modelName, 'private'); + createdModelIds.push(modelId); + const { prototypeId } = await createTestPrototype(page, protoName, modelId); + + await page.goto(`/model/${modelId}/library/list`); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + + await openPrototypeContextMenu(page, protoName); + const zipPath = await downloadViaContextMenuItem(page, 'Export Prototype'); + expect(zipPath).toMatch(/prototype_.*\.zip$/i); + + await deletePrototypeViaApi(page, prototypeId); + await page.reload(); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + await expect(page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`)).toHaveCount(0, { + timeout: 10000, + }); + + await importPrototypeZip(page, zipPath, protoName, modelId); + await removeTempDownloadDir(dirname(zipPath)); + + await searchPrototypeLibrary(page, protoName); + await expect(page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first()).toBeVisible({ + timeout: 30000, + }); + + await saveScreenshot(page, 'import-export-prototype-roundtrip'); + }); +}); diff --git a/.agents/tests/layout.spec.ts b/.agents/tests/layout.spec.ts index 50cde812..b2380d2e 100644 --- a/.agents/tests/layout.spec.ts +++ b/.agents/tests/layout.spec.ts @@ -42,6 +42,8 @@ test.describe('Layout & Visual', () => { test('logged-in layout has no broken elements', async ({ page }) => { await loginAsAdmin(page); + await page.goto('/'); + await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1500); const anomalies = await checkLayoutAnomalies(page, 'logged-in-homepage'); diff --git a/.agents/tests/prototype-context-menu.spec.ts b/.agents/tests/prototype-context-menu.spec.ts new file mode 100644 index 00000000..1c7192d4 --- /dev/null +++ b/.agents/tests/prototype-context-menu.spec.ts @@ -0,0 +1,82 @@ +import { test, expect } from '@playwright/test'; +import { + loginAsAdmin, + loginAs, + logout, + saveScreenshot, + createTestUserViaApi, + createTestModelViaApi, + createTestPrototype, + deleteModelViaApi, + deleteTestUserViaApi, + openPrototypeContextMenu, + confirmNameDialog, + searchPrototypeLibrary, + TEST_USER, +} from './helpers'; + +test.describe.configure({ mode: 'serial' }); + +test.describe('Prototype Context Menu - Admin Delete', () => { + test.setTimeout(90000); + + let testUserId: string | null = null; + let modelId: string | null = null; + + test.afterEach(async ({ page }) => { + await loginAsAdmin(page); + if (modelId) { + await deleteModelViaApi(page, modelId).catch(() => {}); + modelId = null; + } + if (testUserId) { + await deleteTestUserViaApi(page, testUserId).catch(() => {}); + testUserId = null; + } + }); + + test('admin can delete another user prototype via context menu', async ({ page }) => { + const timestamp = Date.now(); + const modelName = `E2E_AdminDeleteModel_${timestamp}`; + const protoName = `E2E_AdminDeleteProto_${timestamp}`; + + await loginAsAdmin(page); + const testUser = await createTestUserViaApi(page, { + email: `e2e_admin_delete_${timestamp}@example.com`, + password: TEST_USER.password, + name: `E2E User ${timestamp}`, + }); + testUserId = testUser.id; + + await logout(page); + await loginAs(page, testUser.email, TEST_USER.password); + modelId = await createTestModelViaApi(page, modelName, 'private'); + await createTestPrototype(page, protoName, modelId); + + await logout(page); + await loginAsAdmin(page); + + await page.goto(`/model/${modelId}/library/list`); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + + const protoCard = page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`).first(); + await expect(protoCard).toBeVisible({ timeout: 20000 }); + + await openPrototypeContextMenu(page, protoName); + await expect(page.getByRole('menuitem', { name: 'Delete Prototype' })).toBeVisible({ + timeout: 10000, + }); + await page.getByRole('menuitem', { name: 'Delete Prototype' }).click(); + await confirmNameDialog(page, protoName); + + await page.reload(); + await page.waitForTimeout(3000); + await searchPrototypeLibrary(page, protoName); + await expect(page.locator(`[data-id^="prototype-item-"]:has-text("${protoName}")`)).toHaveCount(0, { + timeout: 15000, + }); + + await saveScreenshot(page, 'prototype-admin-context-delete'); + }); +}); diff --git a/.agents/tests/prototype-dashboard.spec.ts b/.agents/tests/prototype-dashboard.spec.ts index acf6ca60..b5020c7f 100644 --- a/.agents/tests/prototype-dashboard.spec.ts +++ b/.agents/tests/prototype-dashboard.spec.ts @@ -13,6 +13,7 @@ test.describe('Prototype Dashboard', () => { }); test('4: add built-in Terminal widget and save', async ({ page }) => { + test.setTimeout(120000); const protoName = `E2E_Dashboard_${Date.now()}`; const modelId = await createTestModelViaApi(page, `E2E_Dash_Model_${Date.now()}`, 'public'); const { prototypeId } = await createTestPrototype(page, protoName, modelId); @@ -44,12 +45,16 @@ test.describe('Prototype Dashboard', () => { timeout: 10000, }); - const terminalWidget = page.locator('.widget-list-item-name', { hasText: 'Terminal' }).first(); + const terminalWidget = page + .locator('.cursor-pointer.border') + .filter({ has: page.locator('.widget-list-item-name', { hasText: 'Terminal' }) }) + .first(); await expect(terminalWidget).toBeVisible({ timeout: 10000 }); await terminalWidget.click(); - await page.waitForTimeout(500); - await page.getByRole('button', { name: 'Add selected widget' }).click(); + const addSelectedBtn = page.getByRole('button', { name: 'Add selected widget' }); + await expect(addSelectedBtn).toBeEnabled({ timeout: 20000 }); + await addSelectedBtn.click(); await page.waitForTimeout(1500); const saveBtn = page.locator('[data-id="dashboard-save-button"]');