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
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -872,10 +872,10 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vrt-screenshots
# QNBS-v3: upload both baselines and diff outputs so PR reviewers can compare visually.
# QNBS-v3: upload both baselines and diff outputs so PR reviewers can compare visually — playwright.config.ts sets no outputDir, so Playwright's default (repo-root test-results/, not tests/e2e/test-results/) is where actual/diff failure images actually land.
path: |
tests/e2e/visual-regression.spec.ts-snapshots/
tests/e2e/test-results/
test-results/
retention-days: 7

# QNBS-v3: same playwright.config.ts reporter as the required e2e job, produced independently on this runner — previously never uploaded.
Expand Down
47 changes: 39 additions & 8 deletions tests/e2e/visual-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@
* Local shortcut: pnpm run test:vrt
*/
import { expect, test } from '@playwright/test';
import { clickNavItem, ensureBlankProject, selectEnglish, waitForSpaReady } from './helpers';

// QNBS-v3: Skip in the main E2E job (PLAYWRIGHT_SKIP_VRT=true) — handled by the dedicated VRT job
// that serves the production dist build. Running against the dev server here would compare against
// production-build baselines and always mismatch on unrelated HMR/port differences.

// QNBS-v3: fully-qualified, VRT-local entry, deliberately bypassing the shared baseURL — every other e2e spec's page.goto('/') targets Vite's dev server (which serves at root regardless), while VRT's static server genuinely serves the app under this GH-Pages-style subpath, so scoping this here avoids any risk to the rest of the suite's navigation.
const APP_ENTRY = 'http://127.0.0.1:3000/WorldScript-Studio/';

test.describe('Visual regression', () => {
test.use({ viewport: { width: 1280, height: 720 } });

Expand All @@ -23,6 +28,13 @@ test.describe('Visual regression', () => {
);
});

// QNBS-v3: proves the served page is actually WorldScript Studio before any screenshot — a static-file-server fallback (e.g. an http-server directory listing) has a visible <body> too, so a body-visibility check alone can't tell the two apart; waitForSpaReady's own landmarks (#sidebar/nav-mobile/welcome-portal) already can't exist on a listing page, and the title/text checks below are an independent second signal.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async function assertRealAppLoaded(page: import('@playwright/test').Page) {
await waitForSpaReady(page);
await expect(page).not.toHaveTitle(/Index of/i);
await expect(page.locator('body')).not.toContainText('Index of /');
}

async function settle(page: import('@playwright/test').Page) {
await page.waitForLoadState('load');
await page.evaluate(async () => {
Expand All @@ -43,31 +55,50 @@ test.describe('Visual regression', () => {
timeout: 30_000,
};

// QNBS-v3: APP_ENTRY, not '/' — an absolute-path goto discards the shared baseURL's entire path per the URL spec, landing on the static server's bare root instead of the app (this was the root cause of every VRT baseline actually being an http-server directory listing).
test('home / dashboard loads', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.goto(APP_ENTRY, { waitUntil: 'domcontentloaded' });
await assertRealAppLoaded(page);
await settle(page);
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveScreenshot('home.png', opts);
});

// QNBS-v3: '#view=writer' was never a real route (no code in the app parses it) — reuses the same clickNavItem/ensureBlankProject navigation every other e2e spec already relies on, instead of a URL fragment the router never read. 'manuscript' and 'writer' are distinct views (types.ts's View union); the canonical nav match for the actual Writer/editor view used across the rest of this suite (writer.spec.ts et al.) is /AI Writing Studio|Writer/i, not /Manuscript/i.
test('writer view loads', async ({ page }) => {
await page.goto('/#view=writer', { waitUntil: 'domcontentloaded' });
await page.goto(APP_ENTRY, { waitUntil: 'domcontentloaded' });
await assertRealAppLoaded(page);
await selectEnglish(page);
await ensureBlankProject(page);
await clickNavItem(page, /AI Writing Studio|Writer/i);
// QNBS-v3: proves the Writer view specifically loaded, not just "some" app view — a screenshot diff alone can silently under-detect a wrong destination when both the wrong and right pages happen to be sparse enough to fall under the pixel-diff threshold.
await expect(page.getByTestId('writer-studio-editor').first()).toBeVisible();
await settle(page);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveScreenshot('writer.png', opts);
});

test('characters view loads', async ({ page }) => {
await page.goto('/#view=characters', { waitUntil: 'domcontentloaded' });
await page.goto(APP_ENTRY, { waitUntil: 'domcontentloaded' });
await assertRealAppLoaded(page);
await selectEnglish(page);
await ensureBlankProject(page);
await clickNavItem(page, /Characters/i);
// QNBS-v3: proves the Characters view specifically loaded — see the writer test's identical rationale above.
await expect(page.getByRole('button', { name: /Add Manually/i })).toBeVisible();
await settle(page);
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveScreenshot('characters.png', opts);
});

test('settings view loads', async ({ page }) => {
await page.goto('/#view=settings', { waitUntil: 'domcontentloaded' });
await page.goto(APP_ENTRY, { waitUntil: 'domcontentloaded' });
await assertRealAppLoaded(page);
await selectEnglish(page);
await ensureBlankProject(page);
await clickNavItem(page, /Settings/i);
// QNBS-v3: proves the Settings view specifically loaded — see the writer test's identical rationale above.
await expect(
page.getByRole('heading', { name: /Settings|Einstellungen/i }).first(),
).toBeVisible();
await settle(page);
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveScreenshot('settings.png', opts);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading