Skip to content

Commit 3b99c1a

Browse files
committed
test(e2e): scaffold Playwright with smoke specs + CI workflow
Adds a Playwright harness so user-journey regressions can be caught in CI instead of on customer calls. This is the second tier of the ui-testing-framework work. New: * playwright.config.ts — chromium-only, baseURL via PLAYWRIGHT_BASE_URL, trace/screenshot/video retained on failure, 30s timeout. Default target is http://localhost:8080 (df-docker-dev); CI can override for a nightly canary against crucible. * e2e/fixtures/admin-login.ts — UI-based login helper. (localStorage seed does not work — the app hydrates userData from a cookie + the /session roundtrip.) * e2e/smoke.spec.ts — PASSING. Covers: login + app shell paint, no uncaught JS exceptions, no raw transloco keys leaking (nav.ai.nav), ace-builds asset 200s, top-nav entries navigate without 5xx. * e2e/event-scripts.spec.ts — `test.fixme`. Full intent is laid out; selector work around lazy-loaded routes via hash navigation needs follow-up. * e2e/api-connections.spec.ts, e2e/roles.spec.ts, e2e/mcp-service.spec.ts — `test.fixme` stubs with intent comments. * .github/workflows/e2e.yml — runs on push/PR to develop against a spun-up df-docker-dev stack, plus nightly cron against a configurable URL, plus manual dispatch. * npm scripts: e2e, e2e:ui, e2e:smoke. * jest.config.js: testPathIgnorePatterns exclude e2e/ so Jest doesn't try to run Playwright specs.
1 parent cc7feb4 commit 3b99c1a

12 files changed

Lines changed: 465 additions & 0 deletions

.github/workflows/e2e.yml

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: E2E (Playwright)
2+
3+
on:
4+
push:
5+
branches: ['develop']
6+
pull_request:
7+
branches: ['develop']
8+
schedule:
9+
# Nightly canary against crucible so drift is caught before a customer does.
10+
- cron: '0 7 * * *'
11+
workflow_dispatch:
12+
inputs:
13+
target_url:
14+
description: 'Base URL to run E2E against'
15+
required: false
16+
default: ''
17+
18+
jobs:
19+
smoke:
20+
runs-on: ubuntu-latest
21+
timeout-minutes: 20
22+
23+
steps:
24+
- uses: actions/checkout@v4
25+
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: 22.x
29+
cache: 'npm'
30+
31+
- run: npm ci
32+
33+
- name: Install Playwright Browsers
34+
run: npx playwright install chromium --with-deps
35+
36+
# For PRs and pushes we spin up a minimal DreamFactory stack so the
37+
# smoke specs run against a real API. The stack is provided by the
38+
# df-docker-dev sibling repo checked out into the runner.
39+
- name: Check out df-docker-dev
40+
if: github.event_name == 'push' || github.event_name == 'pull_request'
41+
uses: actions/checkout@v4
42+
with:
43+
repository: dreamfactorysoftware/df-docker-dev
44+
path: df-docker-dev
45+
fetch-depth: 1
46+
47+
- name: Start DreamFactory stack
48+
if: github.event_name == 'push' || github.event_name == 'pull_request'
49+
working-directory: df-docker-dev
50+
run: |
51+
docker compose up -d web mysql redis
52+
# wait for /system/environment to come back 200
53+
timeout 120 bash -c 'until curl -fsS http://localhost:8080/api/v2/system/environment > /dev/null; do sleep 2; done'
54+
55+
- name: Resolve target URL
56+
id: target
57+
run: |
58+
if [ -n "${{ github.event.inputs.target_url }}" ]; then
59+
echo "url=${{ github.event.inputs.target_url }}" >> "$GITHUB_OUTPUT"
60+
elif [ "${{ github.event_name }}" = "schedule" ]; then
61+
echo "url=${{ secrets.E2E_NIGHTLY_URL }}" >> "$GITHUB_OUTPUT"
62+
else
63+
echo "url=http://localhost:8080" >> "$GITHUB_OUTPUT"
64+
fi
65+
66+
- name: Run Playwright tests
67+
env:
68+
PLAYWRIGHT_BASE_URL: ${{ steps.target.outputs.url }}
69+
DF_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL || 'admin@dreamfactory.com' }}
70+
DF_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'passwordpassword' }}
71+
run: npm run e2e
72+
73+
- uses: actions/upload-artifact@v4
74+
if: always()
75+
with:
76+
name: playwright-report
77+
path: playwright-report/
78+
retention-days: 7

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,9 @@ testem.log
4242
Thumbs.db
4343

4444
.config/
45+
46+
# Playwright
47+
/test-results/
48+
/playwright-report/
49+
/blob-report/
50+
/playwright/.cache/

e2e/api-connections.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { test } from '@playwright/test';
2+
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
3+
4+
/**
5+
* API connection (database) CRUD.
6+
*
7+
* TODO — flesh out once the sidenav navigation pattern is stable
8+
* (see the TODO in event-scripts.spec.ts). Intent:
9+
* 1. Navigate to API Connections → Database
10+
* 2. Click +, pick "SQL Server" or "SQLite"
11+
* 3. Fill out the minimum config fields, save
12+
* 4. Assert the service appears in the list
13+
* 5. Click the row, edit label, save
14+
* 6. Delete, assert it's gone
15+
*/
16+
test.fixme('API Connections: create → edit → delete a database service', async ({
17+
page,
18+
}) => {
19+
await loginAsAdmin(page);
20+
await waitForAppReady(page);
21+
// TODO
22+
});

e2e/event-scripts.spec.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { test, expect } from '@playwright/test';
2+
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
3+
4+
/**
5+
* End-to-end regression for the Event Scripts create flow — the path that
6+
* was broken for the Triskele customer on 2026-04-22.
7+
*
8+
* Covers the entire chain that broke during that fire-drill:
9+
* - form opens without a stuck spinner (loading-spinner race fix)
10+
* - Service dropdown populates with raw service names (/system/event
11+
* services_only fast path + case-interceptor exemption)
12+
* - Script Type dropdown populates (response lookup uses raw key)
13+
* - Script Method dropdown populates
14+
* - Save produces a 201/200, not a 400 "No record(s) detected"
15+
*/
16+
test.describe('Event Scripts — create flow', () => {
17+
// TODO: this spec navigates via hash-routing, which doesn't reliably
18+
// trigger the lazy-loaded route resolver on `page.goto('#/event-scripts')`.
19+
// Need to either click through the sidenav (resolved reliably by role
20+
// selectors once the welcome/license flow is accounted for) or switch
21+
// the app to path-based routing. Keeping the spec scaffolded so the
22+
// intent is visible and fixing the navigation is small follow-up work.
23+
test.fixme('create an event script end-to-end', async ({ page }) => {
24+
await loginAsAdmin(page);
25+
await waitForAppReady(page);
26+
27+
// Navigate into Event Scripts. Use a hard reload of the full URL so
28+
// the hash router definitely re-evaluates (in-app hash-only changes
29+
// from page.goto don't always trigger lazy-loaded route resolvers).
30+
await page.goto('/dreamfactory/dist/#/event-scripts', {
31+
waitUntil: 'networkidle',
32+
timeout: 15_000,
33+
});
34+
35+
// Click the + button. DfManageTable renders a mat-mini-fab with class
36+
// .save-btn for the "New Entry" action.
37+
const addBtn = page.locator('button.save-btn').first();
38+
await expect(addBtn).toBeVisible({ timeout: 15_000 });
39+
await addBtn.click();
40+
41+
// Spinner must clear. If the loading-spinner race is back, this
42+
// waits forever until the test timeout.
43+
await expect(
44+
page.locator('mat-progress-spinner, .loading-spinner')
45+
).toHaveCount(0, { timeout: 20_000 });
46+
47+
// Service dropdown
48+
const serviceSelect = page.getByLabel(/^service$/i);
49+
await serviceSelect.click();
50+
// `db` is always present in a dev-docker setup; any underscore service
51+
// also exercises the case-interceptor exemption.
52+
const dbOption = page.getByRole('option', { name: /^db$/ });
53+
await expect(dbOption).toBeVisible({ timeout: 10_000 });
54+
await dbOption.click();
55+
56+
// Script Type dropdown must populate. Empty = the pre-fix bug.
57+
const typeSelect = page.getByLabel(/script\s*type/i);
58+
await typeSelect.click();
59+
const firstType = page.getByRole('option').first();
60+
await expect(firstType).toBeVisible({ timeout: 10_000 });
61+
// Pick a type without {table_name} templating so selectedRoute()
62+
// alone produces a valid completeScriptName.
63+
const dbType = page.getByRole('option', { name: /^db$/ });
64+
await dbType.click();
65+
66+
// Script Method
67+
const methodSelect = page.getByLabel(/script\s*method/i);
68+
await methodSelect.click();
69+
const firstMethod = page
70+
.getByRole('option', { name: /\.get\.pre_process$/ })
71+
.first();
72+
await expect(firstMethod).toBeVisible({ timeout: 10_000 });
73+
await firstMethod.click();
74+
75+
// Grab the method text we picked so we can clean up after save.
76+
// At this point completeScriptName === selectedRouteItem.
77+
78+
// Activate the script and add a one-line body.
79+
const isActiveToggle = page.getByLabel(/active/i).first();
80+
await isActiveToggle.click();
81+
82+
// Save
83+
const saveBtn = page.getByRole('button', { name: /^save$/i });
84+
const savePromise = page.waitForResponse(
85+
r =>
86+
r.url().includes('/api/v2/system/event_script') &&
87+
r.request().method() === 'POST'
88+
);
89+
await saveBtn.click();
90+
const resp = await savePromise;
91+
expect(
92+
[200, 201].includes(resp.status()),
93+
`expected save to succeed, got ${resp.status()}: ${await resp.text()}`
94+
).toBe(true);
95+
});
96+
});

e2e/fixtures/admin-login.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Page, expect } from '@playwright/test';
2+
3+
export const ADMIN_EMAIL =
4+
process.env.DF_ADMIN_EMAIL ?? 'admin@dreamfactory.com';
5+
export const ADMIN_PASSWORD =
6+
process.env.DF_ADMIN_PASSWORD ?? 'passwordpassword';
7+
8+
/**
9+
* Log in via the actual UI. The admin UI keeps the session in a cookie
10+
* and userData in-memory; a synthetic localStorage seed does not work
11+
* because no code path hydrates userData from storage at bootstrap.
12+
*/
13+
export async function loginAsAdmin(page: Page) {
14+
await page.goto('/dreamfactory/dist/#/auth/login');
15+
// Labels come from the userManagement i18n bundle — "Enter Email" /
16+
// "Enter Password" in English. Use type-based selectors to stay
17+
// language-agnostic.
18+
const email = page.locator('input[type="email"]').first();
19+
await expect(email).toBeVisible({ timeout: 15_000 });
20+
await email.fill(ADMIN_EMAIL);
21+
await page.locator('input[type="password"]').first().fill(ADMIN_PASSWORD);
22+
23+
const loginReq = page.waitForResponse(
24+
r =>
25+
r.url().includes('/api/v2/system/admin/session') &&
26+
r.request().method() === 'POST'
27+
);
28+
await page.getByRole('button', { name: /^login$/i }).click();
29+
const resp = await loginReq;
30+
expect(
31+
resp.ok(),
32+
`admin login HTTP ${resp.status()}: ${await resp.text()}`
33+
).toBe(true);
34+
35+
// After login the router navigates off /auth/login to the home route.
36+
await page.waitForURL(url => !url.toString().includes('/auth/login'), {
37+
timeout: 15_000,
38+
});
39+
}
40+
41+
/** Wait for the admin UI shell to be fully painted. */
42+
export async function waitForAppReady(page: Page) {
43+
await page.waitForSelector('mat-toolbar, mat-sidenav, nav', {
44+
timeout: 15_000,
45+
});
46+
}

e2e/mcp-service.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { test } from '@playwright/test';
2+
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
3+
4+
/**
5+
* MCP service + custom function tool round-trip.
6+
*
7+
* TODO — pending stable sidenav navigation (see event-scripts.spec.ts).
8+
* Intent:
9+
* 1. Create an MCP service via the admin UI (type = MCP)
10+
* 2. Add a custom function tool with a simple body: `return a + b;`
11+
* 3. Save — assert the tool row lands in mcp_custom_tools (via API)
12+
* 4. Invoke the tool via /mcp/{service} tools/call and assert result
13+
*
14+
* This journey previously had three silently-dropped save bugs (see
15+
* df-mcp-server PR #35 "persist custom tools on service create and on
16+
* re-save without ids"). End-to-end coverage would have caught them
17+
* before a customer install.
18+
*/
19+
test.fixme('MCP: create service with custom function tool + invoke it', async ({
20+
page,
21+
}) => {
22+
await loginAsAdmin(page);
23+
await waitForAppReady(page);
24+
// TODO
25+
});

e2e/roles.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test } from '@playwright/test';
2+
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
3+
4+
/**
5+
* Roles CRUD.
6+
*
7+
* TODO — pending stable sidenav navigation (see event-scripts.spec.ts).
8+
* Intent:
9+
* 1. Navigate to Role Based Access → Roles
10+
* 2. Create a role with restricted service access
11+
* 3. Assign a service + verb matrix
12+
* 4. Save, reload, assert persisted
13+
* 5. Delete
14+
*/
15+
test.fixme('Roles: create a role with restricted service access', async ({
16+
page,
17+
}) => {
18+
await loginAsAdmin(page);
19+
await waitForAppReady(page);
20+
// TODO
21+
});

e2e/smoke.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { test, expect } from '@playwright/test';
2+
import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login';
3+
4+
test.describe('Admin UI smoke', () => {
5+
test('admin login + app shell loads without console errors', async ({
6+
page,
7+
}) => {
8+
// Track uncaught JS exceptions only. Generic "Failed to load resource"
9+
// notices (4xx/5xx fetches) are covered separately by the network
10+
// assertions below and are too noisy for a smoke check.
11+
const jsErrors: string[] = [];
12+
page.on('pageerror', err => jsErrors.push(err.message));
13+
14+
await loginAsAdmin(page);
15+
await waitForAppReady(page);
16+
17+
// Every nav label should be translated — raw Transloco keys like
18+
// "nav.ai.nav" mean the i18n bundle didn't load. This was the
19+
// clearest tell of the stale public/ issue on the Triskele install.
20+
const body = await page.textContent('body');
21+
expect(body, 'raw transloco key leaked through').not.toMatch(
22+
/\bnav\.[a-z]+\.(nav|title)\b/
23+
);
24+
25+
// Static assets under /dreamfactory/dist/assets must resolve. A 404
26+
// here is the original ace-builds + fonts bug class.
27+
const aceModeResponse = await page.request.get(
28+
'/dreamfactory/dist/assets/ace-builds/mode-javascript.js'
29+
);
30+
expect(aceModeResponse.ok(), 'ace mode asset must 200').toBe(true);
31+
32+
expect(jsErrors, 'page threw uncaught JS exceptions').toEqual([]);
33+
});
34+
35+
test('top-nav entries navigate without errors', async ({ page }) => {
36+
await loginAsAdmin(page);
37+
await waitForAppReady(page);
38+
39+
// Paths taken from routes.ts. Each should render without throwing.
40+
const paths = [
41+
'/api-connections/api-types',
42+
'/api-connections/roles',
43+
'/api-connections/api-keys',
44+
'/event-scripts',
45+
'/system-settings/config',
46+
];
47+
48+
for (const p of paths) {
49+
const url = `/dreamfactory/dist/#${p}`;
50+
const responses: number[] = [];
51+
page.on('response', r => {
52+
if (r.url().includes('/api/v2/')) responses.push(r.status());
53+
});
54+
await page.goto(url);
55+
// Wait for any xhr to land or for the page to settle.
56+
await page.waitForLoadState('networkidle', { timeout: 15_000 });
57+
58+
// No 5xx on any backend call triggered by the navigation.
59+
expect(
60+
responses.filter(s => s >= 500),
61+
`${p} produced 5xx responses`
62+
).toEqual([]);
63+
}
64+
});
65+
});

jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module.exports = {
44
moduleNameMapper: {
55
'^src/(.*)$': '<rootDir>/src/$1',
66
},
7+
// Playwright specs live under e2e/ and must never be picked up by Jest.
8+
testPathIgnorePatterns: ['/node_modules/', '<rootDir>/e2e/'],
79
transformIgnorePatterns: [
810
'node_modules/(?!@angular|swagger-ui|react-syntax-highlighter|swagger-client|@ngneat|@fortawesome)',
911
],

0 commit comments

Comments
 (0)