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
19 changes: 19 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
build:

runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- uses: actions/checkout@v4
Expand All @@ -36,3 +37,21 @@ jobs:
- name: Test with pytest
run: |
pytest
- name: Set up Node.js 24
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
- name: Install browser-test dependencies
run: |
npm ci
npx playwright install --with-deps chromium
- name: Test browser workflows
run: npm run test:e2e
- name: Upload browser-test diagnostics
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-diagnostics
path: e2e/test-results/
if-no-files-found: ignore
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@ tmp-images/

# Node modules
node_modules/
package-lock.json
test-results/
playwright-report/

# Parallax Maker
appstate-*/
appstate-*/
67 changes: 67 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Browser characterization tests

These Playwright tests exercise the real Dash UI, callbacks, persistence, canvas,
image composition, and exporters. The test-only server replaces only expensive
model inference with deterministic images and masks.

Run the suite with:

```sh
npm ci
npx playwright install chromium
npm run test:e2e
```

Run the command from the repository root; the server process deliberately uses
the caller's working directory so Python package and virtualenv paths resolve.

The default server command is:

```sh
python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050
```

The harness can target another interpreter, command, or already-running server:

```sh
E2E_SERVER_COMMAND='poetry run python -m parallax_maker.e2e_server --host 127.0.0.1 --port 8050' npm run test:e2e
E2E_BASE_URL='http://127.0.0.1:9000' E2E_SKIP_SERVER=1 npm run test:e2e
```

The suite deliberately uses one worker because the current application has a
process-global `AppState.cache` and filesystem-backed state. Every restore gets
a fresh deterministic state directory. The harness uses a fixed Chromium
viewport and device scale factor, records a trace/video/screenshot on failure,
and fails on uncaught page exceptions, unexpected console errors, failed
same-origin requests, or same-origin HTTP error responses.

The scenarios cover:

- upload, deterministic depth, thresholds, and three generated slices;
- point segmentation, including positive and negative points;
- a real canvas stroke, mask persistence, three exact checkerboard candidates,
candidate selection, apply, and undo;
- restoration of images, slices, prompts, camera controls, model, and theme;
- downloaded glTF structure and the current server-side animation behavior.

The animation test intentionally asserts that no browser download occurs. The
current callback renders numbered PNG files server-side and logs completion but
does not populate `download-animation`. Change this assertion when that product
behavior is fixed.

## Selector contract for a future Svelte UI

Most selectors use visible labels, button text, or existing stable IDs. A Svelte
replacement should preserve accessible names for the user-facing contract. The
framework-specific selectors are centralized in `helpers/app.ts`; treat that file
as the current Dash UI adapter. A Svelte adapter should also implement the same
test-only fixture/state API so the behavioral scenario files stay unchanged. The
few image-workflow elements that would benefit from explicit test IDs are:

- `input-image`, `paint-canvas`, and `preview-canvas`;
- `depth-map-image`;
- `slice-image-{index}` and `slice-overlay-{index}`;
- `inpainting-candidate-{index}`;
- `slice-undo-{index}` and `slice-redo-{index}`.

Do not add test IDs to every control; semantic roles and labels are preferred.
93 changes: 93 additions & 0 deletions e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test as base, expect, type ConsoleMessage, type Page } from '@playwright/test';

type BrowserDiagnostics = {
consoleErrors: string[];
pageErrors: string[];
failedRequests: string[];
errorResponses: string[];
};

const ignoredExternalFailures = [
/kit\.fontawesome\.com/,
/cdn\.jsdelivr\.net/,
/unpkg\.com/,
// Chromium omits the URL from this console message. A same-origin DNS
// failure is still caught by requestfailed below; the E2E app uses an IP URL.
/Failed to load resource: net::ERR_NAME_NOT_RESOLVED/,
];

function isIgnoredExternalFailure(text: string): boolean {
return ignoredExternalFailures.some((pattern) => pattern.test(text));
}

function consoleText(message: ConsoleMessage): string {
return `${message.type()}: ${message.text()}`;
}

export const test = base.extend<{ diagnostics: BrowserDiagnostics }>({
diagnostics: [async ({ page, baseURL }, use, testInfo) => {
const diagnostics: BrowserDiagnostics = {
consoleErrors: [],
pageErrors: [],
failedRequests: [],
errorResponses: [],
};
const appOrigin = new URL(baseURL ?? 'http://127.0.0.1:8050').origin;

page.on('console', (message) => {
if (message.type() !== 'error') return;
const text = consoleText(message);
if (!isIgnoredExternalFailure(text)) diagnostics.consoleErrors.push(text);
});
page.on('pageerror', (error) => diagnostics.pageErrors.push(error.stack ?? error.message));
page.on('response', (response) => {
const url = response.url();
if (new URL(url).origin === appOrigin && response.status() >= 400) {
diagnostics.errorResponses.push(`${response.status()} ${response.request().method()} ${url}`);
}
});
page.on('requestfailed', (request) => {
const url = request.url();
const errorText = request.failure()?.errorText ?? 'unknown failure';
const supersededDashCallback =
new URL(url).pathname === '/_dash-update-component' && errorText.includes('ERR_ABORTED');
if (new URL(url).origin === appOrigin && !supersededDashCallback) {
diagnostics.failedRequests.push(
`${request.method()} ${url}: ${errorText}`,
);
}
});

await use(diagnostics);

if (
diagnostics.consoleErrors.length ||
diagnostics.pageErrors.length ||
diagnostics.failedRequests.length ||
diagnostics.errorResponses.length
) {
await testInfo.attach('browser-diagnostics', {
body: JSON.stringify(diagnostics, null, 2),
contentType: 'application/json',
});
}
expect.soft(diagnostics.pageErrors, 'uncaught browser exceptions').toEqual([]);
expect.soft(diagnostics.consoleErrors, 'unexpected browser console errors').toEqual([]);
expect.soft(diagnostics.failedRequests, 'failed same-origin requests').toEqual([]);
expect.soft(diagnostics.errorResponses, 'same-origin HTTP error responses').toEqual([]);
}, { auto: true }],
});

export { expect };

export async function disableAnimations(page: Page): Promise<void> {
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
caret-color: transparent !important;
}
`,
});
}
Loading
Loading