From 3d6bb50728663234c33ff88e7d6e45c657b81883 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Wed, 22 Apr 2026 10:58:37 -0600 Subject: [PATCH 1/4] test(ci): wire jest into CI with regression specs for 2026-04-22 fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the only CI check was `npm run build`, which type-checks but never runs tests. This commit activates jest in CI via a scoped `jest.config.ci.js` so the suite passes today while the broader ESM / @ngneat/transloco migration is tackled separately. Adds regression specs for each bug hit on the customer fire-drill: * df-loading-spinner.service.spec.ts — rapid toggles inside one tick now settle to false (was stuck-on due to stale BehaviorSubject read). * case.interceptor.spec.ts — /system/event responses pass through unchanged; /api_docs exemption still works; body request transforms still convert camelCase → snake_case. * df-script-details.submit.spec.ts — submit fallback uses `||` (not `??`) so empty completeScriptName falls back to selectedRouteItem; template wiring verified for all three mat-selects; raw serviceName lookup (no re-introduction of the api_docs → apiDocs rename). Also adds "node" to tsconfig.spec.json types so specs can `readFileSync` the committed HTML/TS for contract assertions. --- .github/workflows/node.js.yml | 5 + jest.config.ci.js | 35 +++++++ package.json | 1 + .../df-script-details.submit.spec.ts | 99 +++++++++++++++++++ .../interceptors/case.interceptor.spec.ts | 91 +++++++++++++++++ .../df-loading-spinner.service.spec.ts | 23 +++++ tsconfig.spec.json | 2 +- 7 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 jest.config.ci.js create mode 100644 src/app/adf-event-scripts/df-script-details/df-script-details.submit.spec.ts create mode 100644 src/app/shared/interceptors/case.interceptor.spec.ts diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index ff1329d4e..64b5c61d8 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -25,4 +25,9 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci + - run: npm run lint --if-present + # `test:ci` runs only the specs that currently pass. The rest are + # quarantined by jest.config.ci.js until the @ngneat/transloco ESM + # migration is completed. New tests should be added to that config. + - run: npm run test:ci - run: npm run build --if-present diff --git a/jest.config.ci.js b/jest.config.ci.js new file mode 100644 index 000000000..2ce45a4d5 --- /dev/null +++ b/jest.config.ci.js @@ -0,0 +1,35 @@ +/** + * CI Jest config. + * + * Extends the base jest.config.js but scopes the run to the specs that + * currently pass. The rest of the suite (55 suites as of 2026-04-22) + * fails because @ngneat/transloco now ships ESM-only (.mjs) and + * jest-preset-angular 13 in the current setup can't resolve it without + * a broader ESM migration. + * + * Tracking: "Jest ESM migration for transloco" — once done, this file + * can be deleted and CI can call the default `npm test`. + * + * New tests should be added to the testMatch list below as they are + * written, so they fail CI if they regress. + */ +const base = require('./jest.config'); + +module.exports = { + ...base, + testMatch: [ + // Regression coverage for the 2026-04-22 customer fire-drill fixes. + '/src/app/shared/services/df-loading-spinner.service.spec.ts', + '/src/app/shared/interceptors/case.interceptor.spec.ts', + '/src/app/adf-event-scripts/df-script-details/df-script-details.submit.spec.ts', + // Existing specs that currently pass. The rest of the suite is + // quarantined until the ESM/transloco migration; route.spec.ts is + // a separate pre-existing assertion mismatch. + '/src/app/shared/utilities/url.spec.ts', + '/src/app/shared/utilities/language.spec.ts', + '/src/app/shared/utilities/case.spec.ts', + '/src/app/shared/utilities/file.spec.ts', + '/src/app/shared/services/df-breakpoint.service.spec.ts', + '/src/app/shared/services/df-theme.service.spec.ts', + ], +}; diff --git a/package.json b/package.json index 8f8b439c2..80a48a291 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build": "ng build", "watch": "ng build --watch --configuration development", "test": "jest --verbose", + "test:ci": "jest --ci --watchAll=false --passWithNoTests --config jest.config.ci.js", "test:coverage": "jest --coverage", "test:watch": "jest --watch", "lint": "ng lint", diff --git a/src/app/adf-event-scripts/df-script-details/df-script-details.submit.spec.ts b/src/app/adf-event-scripts/df-script-details/df-script-details.submit.spec.ts new file mode 100644 index 000000000..d566e03c2 --- /dev/null +++ b/src/app/adf-event-scripts/df-script-details/df-script-details.submit.spec.ts @@ -0,0 +1,99 @@ +/** + * Focused regressions for the submit path in DfScriptDetailsComponent. + * + * These tests do not boot the full component — Angular Router + transloco + + * Material make a full TestBed harness expensive. We exercise the pure + * fallback and grep the committed HTML/TS to prove that the wiring which + * broke a customer install on 2026-04-22 cannot silently regress. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const HTML_SRC = readFileSync( + join(__dirname, 'df-script-details.component.html'), + 'utf8' +); +const TS_SRC = readFileSync( + join(__dirname, 'df-script-details.component.ts'), + 'utf8' +); + +describe('DfScriptDetailsComponent submit logic', () => { + it('falls back on empty completeScriptName to selectedRouteItem', () => { + // selectedServiceItemEvent() resets completeScriptName to '' (not null). + // The old code used `??` which does not treat '' as missing, so + // scriptItem.name became '' and the POST URL dropped the resource name + // producing a 400 "No record(s) detected" from the backend. + const completeScriptName = ''; + const selectedRouteItem = 'test_underscore._schema.get.pre_process'; + + const name = completeScriptName || selectedRouteItem; + + expect(name).toBe('test_underscore._schema.get.pre_process'); + }); + + it('prefers completeScriptName when set by selectedTable/selectedRoute', () => { + const completeScriptName = 'db._schema.table_name.get.pre_process'; + const selectedRouteItem = 'db._schema.{table_name}.get.pre_process'; + + const name = completeScriptName || selectedRouteItem; + + expect(name).toBe('db._schema.table_name.get.pre_process'); + }); + + it('submit source uses `||` (not `??`) for the name fallback', () => { + // Guard the exact call site — `??` would regress the empty-string bug. + expect(TS_SRC).toMatch( + /name:\s*this\.completeScriptName\s*\|\|\s*this\.selectedRouteItem/ + ); + expect(TS_SRC).not.toMatch( + /name:\s*this\.completeScriptName\s*\?\?\s*this\.selectedRouteItem/ + ); + }); +}); + +describe('DfScriptDetailsComponent template wiring contract', () => { + // The three mat-selects must fire their change handlers. Removing any + // (selectionChange) binding was what broke saving on the customer call. + it('Script Method mat-select has (selectionChange)="selectedRoute()"', () => { + const methodBlock = HTML_SRC.match( + /scripts\.scriptMethod[\s\S]*?<\/mat-form-field>/ + ); + expect(methodBlock).not.toBeNull(); + expect(methodBlock![0]).toContain('(selectionChange)="selectedRoute()"'); + }); + + it('Service mat-select has (selectionChange)="selectedServiceItemEvent()"', () => { + const serviceBlock = HTML_SRC.match( + /'service' \| transloco[\s\S]*?<\/mat-form-field>/ + ); + expect(serviceBlock).not.toBeNull(); + expect(serviceBlock![0]).toContain( + '(selectionChange)="selectedServiceItemEvent()"' + ); + }); + + it('Script Type mat-select has (selectionChange)="selectedEventItemEvent()"', () => { + const typeBlock = HTML_SRC.match( + /scripts\.scriptType[\s\S]*?<\/mat-form-field>/ + ); + expect(typeBlock).not.toBeNull(); + expect(typeBlock![0]).toContain( + '(selectionChange)="selectedEventItemEvent()"' + ); + }); +}); + +describe('DfScriptDetailsComponent service lookup key', () => { + // /system/event responses are exempt from the case interceptor + // (see case.interceptor.ts). The component must look up + // response[serviceName] with the raw service name; the hardcoded + // api_docs -> apiDocs rename must NOT return. + it('does not reintroduce the api_docs → apiDocs rename', () => { + expect(TS_SRC).not.toMatch(/serviceKey\s*=\s*['"]apiDocs['"]/); + }); + + it('looks up events using the raw serviceName', () => { + expect(TS_SRC).toMatch(/response\[serviceName\]/); + }); +}); diff --git a/src/app/shared/interceptors/case.interceptor.spec.ts b/src/app/shared/interceptors/case.interceptor.spec.ts new file mode 100644 index 000000000..a5b859e8f --- /dev/null +++ b/src/app/shared/interceptors/case.interceptor.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { + HttpClient, + provideHttpClient, + withInterceptors, +} from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { caseInterceptor } from './case.interceptor'; + +describe('caseInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([caseInterceptor])), + provideHttpClientTesting(), + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('transforms snake_case response keys to camelCase for normal endpoints', done => { + http.get('/api/v2/system/service').subscribe((body: any) => { + expect(body.some_key).toBeUndefined(); + expect(body.someKey).toBe('v'); + done(); + }); + const req = httpMock.expectOne('/api/v2/system/service'); + req.flush( + { some_key: 'v' }, + { headers: { 'Content-Type': 'application/json' } } + ); + }); + + it('passes /system/event responses through unchanged', done => { + // Regression: event names are response keys and the backend matches + // scripts by the raw string. Transforming to camelCase would break the + // Script Type dropdown and corrupt saved script names for any service + // whose name contains an underscore. + const raw = { + test_underscore: { + 'test_underscore._schema': { endpoints: ['a.b.c'] }, + }, + }; + http + .get('/api/v2/system/event?services_only=true') + .subscribe((body: any) => { + expect(body).toEqual(raw); + done(); + }); + const req = httpMock.expectOne('/api/v2/system/event?services_only=true'); + req.flush(raw, { headers: { 'Content-Type': 'application/json' } }); + }); + + it('passes /api_docs responses through unchanged (OpenAPI keys must survive)', done => { + const raw = { 'x-custom': 1, paths: { '/foo_bar': {} } }; + http.get('/api/v2/api_docs/swagger').subscribe((body: any) => { + expect(body).toEqual(raw); + done(); + }); + const req = httpMock.expectOne('/api/v2/api_docs/swagger'); + req.flush(raw, { headers: { 'Content-Type': 'application/json' } }); + }); + + it('converts camelCase request bodies to snake_case', () => { + http + .post('/api/v2/system/service', { isActive: true, apiKey: 'x' }) + .subscribe(); + const req = httpMock.expectOne('/api/v2/system/service'); + expect(req.request.body).toEqual({ is_active: true, api_key: 'x' }); + req.flush({}); + }); + + it('does not transform requests that fall outside /api', () => { + http.get('/dreamfactory/dist/assets/i18n/en.json').subscribe(); + const req = httpMock.expectOne('/dreamfactory/dist/assets/i18n/en.json'); + req.flush( + { some_key: 'v' }, + { headers: { 'Content-Type': 'application/json' } } + ); + // no assertion besides: no error, no transform applied + }); +}); diff --git a/src/app/shared/services/df-loading-spinner.service.spec.ts b/src/app/shared/services/df-loading-spinner.service.spec.ts index c022c94c4..5474c3993 100644 --- a/src/app/shared/services/df-loading-spinner.service.spec.ts +++ b/src/app/shared/services/df-loading-spinner.service.spec.ts @@ -49,4 +49,27 @@ describe('DfLoadingSpinnerService', () => { service.active = false; }); + + it('settles to inactive when three requests start and finish inside one tick', async () => { + // Regression for the stuck spinner bug: the previous setTimeout-based + // implementation compared shouldBeActive to active$.value, which was + // stale during rapid toggles and lost the final deactivate. After + // queuedMicrotasks run, the subject must be false. + const observed: boolean[] = []; + service.active.subscribe(v => observed.push(v)); + + // Simulate three concurrent requests starting and finishing in one tick. + service.active = true; + service.active = true; + service.active = true; + service.active = false; + service.active = false; + service.active = false; + + // Flush all pending microtasks. + await Promise.resolve(); + await Promise.resolve(); + + expect(observed[observed.length - 1]).toBe(false); + }); }); diff --git a/tsconfig.spec.json b/tsconfig.spec.json index c7cfc0e27..12dbe820c 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,7 +4,7 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "module": "CommonJs", - "types": ["jest"] + "types": ["jest", "node"] }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] } From 52f2c84a3b1d146bffbd68b82da90d62d1dfbd1b Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Wed, 22 Apr 2026 11:15:54 -0600 Subject: [PATCH 2/4] test(e2e): scaffold Playwright with smoke specs + CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/e2e.yml | 78 ++++++++++++++++++++++++++++++ .gitignore | 6 +++ e2e/api-connections.spec.ts | 22 +++++++++ e2e/event-scripts.spec.ts | 96 +++++++++++++++++++++++++++++++++++++ e2e/fixtures/admin-login.ts | 46 ++++++++++++++++++ e2e/mcp-service.spec.ts | 25 ++++++++++ e2e/roles.spec.ts | 21 ++++++++ e2e/smoke.spec.ts | 65 +++++++++++++++++++++++++ jest.config.js | 2 + package-lock.json | 64 +++++++++++++++++++++++++ package.json | 4 ++ playwright.config.ts | 36 ++++++++++++++ 12 files changed, 465 insertions(+) create mode 100644 .github/workflows/e2e.yml create mode 100644 e2e/api-connections.spec.ts create mode 100644 e2e/event-scripts.spec.ts create mode 100644 e2e/fixtures/admin-login.ts create mode 100644 e2e/mcp-service.spec.ts create mode 100644 e2e/roles.spec.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..8848eb8c0 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,78 @@ +name: E2E (Playwright) + +on: + push: + branches: ['develop'] + pull_request: + branches: ['develop'] + schedule: + # Nightly canary against crucible so drift is caught before a customer does. + - cron: '0 7 * * *' + workflow_dispatch: + inputs: + target_url: + description: 'Base URL to run E2E against' + required: false + default: '' + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: 'npm' + + - run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install chromium --with-deps + + # For PRs and pushes we spin up a minimal DreamFactory stack so the + # smoke specs run against a real API. The stack is provided by the + # df-docker-dev sibling repo checked out into the runner. + - name: Check out df-docker-dev + if: github.event_name == 'push' || github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + repository: dreamfactorysoftware/df-docker-dev + path: df-docker-dev + fetch-depth: 1 + + - name: Start DreamFactory stack + if: github.event_name == 'push' || github.event_name == 'pull_request' + working-directory: df-docker-dev + run: | + docker compose up -d web mysql redis + # wait for /system/environment to come back 200 + timeout 120 bash -c 'until curl -fsS http://localhost:8080/api/v2/system/environment > /dev/null; do sleep 2; done' + + - name: Resolve target URL + id: target + run: | + if [ -n "${{ github.event.inputs.target_url }}" ]; then + echo "url=${{ github.event.inputs.target_url }}" >> "$GITHUB_OUTPUT" + elif [ "${{ github.event_name }}" = "schedule" ]; then + echo "url=${{ secrets.E2E_NIGHTLY_URL }}" >> "$GITHUB_OUTPUT" + else + echo "url=http://localhost:8080" >> "$GITHUB_OUTPUT" + fi + + - name: Run Playwright tests + env: + PLAYWRIGHT_BASE_URL: ${{ steps.target.outputs.url }} + DF_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL || 'admin@dreamfactory.com' }} + DF_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD || 'passwordpassword' }} + run: npm run e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 86d31fece..da8ec06ad 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,9 @@ testem.log Thumbs.db .config/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/api-connections.spec.ts b/e2e/api-connections.spec.ts new file mode 100644 index 000000000..91cdc353b --- /dev/null +++ b/e2e/api-connections.spec.ts @@ -0,0 +1,22 @@ +import { test } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +/** + * API connection (database) CRUD. + * + * TODO — flesh out once the sidenav navigation pattern is stable + * (see the TODO in event-scripts.spec.ts). Intent: + * 1. Navigate to API Connections → Database + * 2. Click +, pick "SQL Server" or "SQLite" + * 3. Fill out the minimum config fields, save + * 4. Assert the service appears in the list + * 5. Click the row, edit label, save + * 6. Delete, assert it's gone + */ +test.fixme('API Connections: create → edit → delete a database service', async ({ + page, +}) => { + await loginAsAdmin(page); + await waitForAppReady(page); + // TODO +}); diff --git a/e2e/event-scripts.spec.ts b/e2e/event-scripts.spec.ts new file mode 100644 index 000000000..63898327f --- /dev/null +++ b/e2e/event-scripts.spec.ts @@ -0,0 +1,96 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +/** + * End-to-end regression for the Event Scripts create flow — the path that + * was broken for the Triskele customer on 2026-04-22. + * + * Covers the entire chain that broke during that fire-drill: + * - form opens without a stuck spinner (loading-spinner race fix) + * - Service dropdown populates with raw service names (/system/event + * services_only fast path + case-interceptor exemption) + * - Script Type dropdown populates (response lookup uses raw key) + * - Script Method dropdown populates + * - Save produces a 201/200, not a 400 "No record(s) detected" + */ +test.describe('Event Scripts — create flow', () => { + // TODO: this spec navigates via hash-routing, which doesn't reliably + // trigger the lazy-loaded route resolver on `page.goto('#/event-scripts')`. + // Need to either click through the sidenav (resolved reliably by role + // selectors once the welcome/license flow is accounted for) or switch + // the app to path-based routing. Keeping the spec scaffolded so the + // intent is visible and fixing the navigation is small follow-up work. + test.fixme('create an event script end-to-end', async ({ page }) => { + await loginAsAdmin(page); + await waitForAppReady(page); + + // Navigate into Event Scripts. Use a hard reload of the full URL so + // the hash router definitely re-evaluates (in-app hash-only changes + // from page.goto don't always trigger lazy-loaded route resolvers). + await page.goto('/dreamfactory/dist/#/event-scripts', { + waitUntil: 'networkidle', + timeout: 15_000, + }); + + // Click the + button. DfManageTable renders a mat-mini-fab with class + // .save-btn for the "New Entry" action. + const addBtn = page.locator('button.save-btn').first(); + await expect(addBtn).toBeVisible({ timeout: 15_000 }); + await addBtn.click(); + + // Spinner must clear. If the loading-spinner race is back, this + // waits forever until the test timeout. + await expect( + page.locator('mat-progress-spinner, .loading-spinner') + ).toHaveCount(0, { timeout: 20_000 }); + + // Service dropdown + const serviceSelect = page.getByLabel(/^service$/i); + await serviceSelect.click(); + // `db` is always present in a dev-docker setup; any underscore service + // also exercises the case-interceptor exemption. + const dbOption = page.getByRole('option', { name: /^db$/ }); + await expect(dbOption).toBeVisible({ timeout: 10_000 }); + await dbOption.click(); + + // Script Type dropdown must populate. Empty = the pre-fix bug. + const typeSelect = page.getByLabel(/script\s*type/i); + await typeSelect.click(); + const firstType = page.getByRole('option').first(); + await expect(firstType).toBeVisible({ timeout: 10_000 }); + // Pick a type without {table_name} templating so selectedRoute() + // alone produces a valid completeScriptName. + const dbType = page.getByRole('option', { name: /^db$/ }); + await dbType.click(); + + // Script Method + const methodSelect = page.getByLabel(/script\s*method/i); + await methodSelect.click(); + const firstMethod = page + .getByRole('option', { name: /\.get\.pre_process$/ }) + .first(); + await expect(firstMethod).toBeVisible({ timeout: 10_000 }); + await firstMethod.click(); + + // Grab the method text we picked so we can clean up after save. + // At this point completeScriptName === selectedRouteItem. + + // Activate the script and add a one-line body. + const isActiveToggle = page.getByLabel(/active/i).first(); + await isActiveToggle.click(); + + // Save + const saveBtn = page.getByRole('button', { name: /^save$/i }); + const savePromise = page.waitForResponse( + r => + r.url().includes('/api/v2/system/event_script') && + r.request().method() === 'POST' + ); + await saveBtn.click(); + const resp = await savePromise; + expect( + [200, 201].includes(resp.status()), + `expected save to succeed, got ${resp.status()}: ${await resp.text()}` + ).toBe(true); + }); +}); diff --git a/e2e/fixtures/admin-login.ts b/e2e/fixtures/admin-login.ts new file mode 100644 index 000000000..cde4dff21 --- /dev/null +++ b/e2e/fixtures/admin-login.ts @@ -0,0 +1,46 @@ +import { Page, expect } from '@playwright/test'; + +export const ADMIN_EMAIL = + process.env.DF_ADMIN_EMAIL ?? 'admin@dreamfactory.com'; +export const ADMIN_PASSWORD = + process.env.DF_ADMIN_PASSWORD ?? 'passwordpassword'; + +/** + * Log in via the actual UI. The admin UI keeps the session in a cookie + * and userData in-memory; a synthetic localStorage seed does not work + * because no code path hydrates userData from storage at bootstrap. + */ +export async function loginAsAdmin(page: Page) { + await page.goto('/dreamfactory/dist/#/auth/login'); + // Labels come from the userManagement i18n bundle — "Enter Email" / + // "Enter Password" in English. Use type-based selectors to stay + // language-agnostic. + const email = page.locator('input[type="email"]').first(); + await expect(email).toBeVisible({ timeout: 15_000 }); + await email.fill(ADMIN_EMAIL); + await page.locator('input[type="password"]').first().fill(ADMIN_PASSWORD); + + const loginReq = page.waitForResponse( + r => + r.url().includes('/api/v2/system/admin/session') && + r.request().method() === 'POST' + ); + await page.getByRole('button', { name: /^login$/i }).click(); + const resp = await loginReq; + expect( + resp.ok(), + `admin login HTTP ${resp.status()}: ${await resp.text()}` + ).toBe(true); + + // After login the router navigates off /auth/login to the home route. + await page.waitForURL(url => !url.toString().includes('/auth/login'), { + timeout: 15_000, + }); +} + +/** Wait for the admin UI shell to be fully painted. */ +export async function waitForAppReady(page: Page) { + await page.waitForSelector('mat-toolbar, mat-sidenav, nav', { + timeout: 15_000, + }); +} diff --git a/e2e/mcp-service.spec.ts b/e2e/mcp-service.spec.ts new file mode 100644 index 000000000..0dcc6968b --- /dev/null +++ b/e2e/mcp-service.spec.ts @@ -0,0 +1,25 @@ +import { test } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +/** + * MCP service + custom function tool round-trip. + * + * TODO — pending stable sidenav navigation (see event-scripts.spec.ts). + * Intent: + * 1. Create an MCP service via the admin UI (type = MCP) + * 2. Add a custom function tool with a simple body: `return a + b;` + * 3. Save — assert the tool row lands in mcp_custom_tools (via API) + * 4. Invoke the tool via /mcp/{service} tools/call and assert result + * + * This journey previously had three silently-dropped save bugs (see + * df-mcp-server PR #35 "persist custom tools on service create and on + * re-save without ids"). End-to-end coverage would have caught them + * before a customer install. + */ +test.fixme('MCP: create service with custom function tool + invoke it', async ({ + page, +}) => { + await loginAsAdmin(page); + await waitForAppReady(page); + // TODO +}); diff --git a/e2e/roles.spec.ts b/e2e/roles.spec.ts new file mode 100644 index 000000000..e8944b901 --- /dev/null +++ b/e2e/roles.spec.ts @@ -0,0 +1,21 @@ +import { test } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +/** + * Roles CRUD. + * + * TODO — pending stable sidenav navigation (see event-scripts.spec.ts). + * Intent: + * 1. Navigate to Role Based Access → Roles + * 2. Create a role with restricted service access + * 3. Assign a service + verb matrix + * 4. Save, reload, assert persisted + * 5. Delete + */ +test.fixme('Roles: create a role with restricted service access', async ({ + page, +}) => { + await loginAsAdmin(page); + await waitForAppReady(page); + // TODO +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 000000000..4828152d1 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +test.describe('Admin UI smoke', () => { + test('admin login + app shell loads without console errors', async ({ + page, + }) => { + // Track uncaught JS exceptions only. Generic "Failed to load resource" + // notices (4xx/5xx fetches) are covered separately by the network + // assertions below and are too noisy for a smoke check. + const jsErrors: string[] = []; + page.on('pageerror', err => jsErrors.push(err.message)); + + await loginAsAdmin(page); + await waitForAppReady(page); + + // Every nav label should be translated — raw Transloco keys like + // "nav.ai.nav" mean the i18n bundle didn't load. This was the + // clearest tell of the stale public/ issue on the Triskele install. + const body = await page.textContent('body'); + expect(body, 'raw transloco key leaked through').not.toMatch( + /\bnav\.[a-z]+\.(nav|title)\b/ + ); + + // Static assets under /dreamfactory/dist/assets must resolve. A 404 + // here is the original ace-builds + fonts bug class. + const aceModeResponse = await page.request.get( + '/dreamfactory/dist/assets/ace-builds/mode-javascript.js' + ); + expect(aceModeResponse.ok(), 'ace mode asset must 200').toBe(true); + + expect(jsErrors, 'page threw uncaught JS exceptions').toEqual([]); + }); + + test('top-nav entries navigate without errors', async ({ page }) => { + await loginAsAdmin(page); + await waitForAppReady(page); + + // Paths taken from routes.ts. Each should render without throwing. + const paths = [ + '/api-connections/api-types', + '/api-connections/roles', + '/api-connections/api-keys', + '/event-scripts', + '/system-settings/config', + ]; + + for (const p of paths) { + const url = `/dreamfactory/dist/#${p}`; + const responses: number[] = []; + page.on('response', r => { + if (r.url().includes('/api/v2/')) responses.push(r.status()); + }); + await page.goto(url); + // Wait for any xhr to land or for the page to settle. + await page.waitForLoadState('networkidle', { timeout: 15_000 }); + + // No 5xx on any backend call triggered by the navigation. + expect( + responses.filter(s => s >= 500), + `${p} produced 5xx responses` + ).toEqual([]); + } + }); +}); diff --git a/jest.config.js b/jest.config.js index 2b1885e24..0d8f97bb3 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,8 @@ module.exports = { moduleNameMapper: { '^src/(.*)$': '/src/$1', }, + // Playwright specs live under e2e/ and must never be picked up by Jest. + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], transformIgnorePatterns: [ 'node_modules/(?!@angular|swagger-ui|react-syntax-highlighter|swagger-client|@ngneat|@fortawesome)', ], diff --git a/package-lock.json b/package-lock.json index c7e79ac2f..f9c3af414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "@angular/cli": "~16.1.6", "@angular/compiler-cli": "^16.1.0", "@ngneat/transloco-keys-manager": "^3.8.0", + "@playwright/test": "^1.59.1", "@types/jest": "^29.5.3", "@types/swagger-ui": "^3.52.0", "@typescript-eslint/eslint-plugin": "5.62.0", @@ -5807,6 +5808,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -17436,6 +17453,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 80a48a291..38f9d18aa 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "test:ci": "jest --ci --watchAll=false --passWithNoTests --config jest.config.ci.js", "test:coverage": "jest --coverage", "test:watch": "jest --watch", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "e2e:smoke": "playwright test e2e/smoke.spec.ts", "lint": "ng lint", "lint:fix": "ng lint --fix", "prettier": "npx prettier --write .", @@ -53,6 +56,7 @@ "@angular/cli": "~16.1.6", "@angular/compiler-cli": "^16.1.0", "@ngneat/transloco-keys-manager": "^3.8.0", + "@playwright/test": "^1.59.1", "@types/jest": "^29.5.3", "@types/swagger-ui": "^3.52.0", "@typescript-eslint/eslint-plugin": "5.62.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..f7dd8ba2d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E config. + * + * Runs against a live DreamFactory admin instance. Defaults to the local + * dev docker at http://localhost:8080 (served from df-docker-dev), but + * any URL can be passed via PLAYWRIGHT_BASE_URL — for example the + * crucible.home.nicdavidson.net box or a CI-spun ephemeral instance. + * + * Admin credentials default to the dev-docker defaults. In CI override + * via DF_ADMIN_EMAIL / DF_ADMIN_PASSWORD. + */ +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, // DF sessions are stateful; run serial to avoid flakes + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['html'], ['github']] : 'list', + timeout: 30_000, + expect: { timeout: 10_000 }, + + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8080', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: process.env.CI ? 'retain-on-failure' : 'off', + ignoreHTTPSErrors: true, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); From c6ea71c6181cfc664ad60ca212e8453764824ef4 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Wed, 22 Apr 2026 11:42:13 -0600 Subject: [PATCH 3/4] ci: drop pre-existing lint gate + scope E2E to manual/nightly only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI issues surfaced on PR #487 opening the ui-testing-framework branch, both self-inflicted: 1. `npm run lint` exposed 47 errors + 360 warnings of pre-existing tech debt. The PR's scope is adding tests, not landing a lint cleanup; gating CI on it blocks this and any other PR until that cleanup happens. Dropping the step for now. A proper lint re-enablement can land on its own PR. 2. The E2E job tried to check out `dreamfactorysoftware/df-docker-dev` which does not exist on the public org — that's a local-only dev stack. Without a shared containerized DreamFactory image the E2E can't provision its own backend. Switching the workflow to manual-dispatch + nightly-cron-only until that infra lands; the smoke specs still work fine when pointed at a live instance via PLAYWRIGHT_BASE_URL. --- .github/workflows/e2e.yml | 38 ++++++++--------------------------- .github/workflows/node.js.yml | 1 - 2 files changed, 8 insertions(+), 31 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8848eb8c0..9c45edfc2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,19 +1,18 @@ name: E2E (Playwright) +# Runs only on-demand for now. Spinning a full DreamFactory stack in GH +# Actions requires a shared df-docker image + secrets wiring that doesn't +# exist yet; wiring it up is tracked as a follow-up. Until then this job +# is driven by manual dispatch (pass a target URL) or a nightly cron +# against a long-running instance (URL in the E2E_NIGHTLY_URL secret). on: - push: - branches: ['develop'] - pull_request: - branches: ['develop'] schedule: - # Nightly canary against crucible so drift is caught before a customer does. - cron: '0 7 * * *' workflow_dispatch: inputs: target_url: - description: 'Base URL to run E2E against' - required: false - default: '' + description: 'Base URL to run E2E against (e.g. http://crucible.example:8080)' + required: true jobs: smoke: @@ -33,34 +32,13 @@ jobs: - name: Install Playwright Browsers run: npx playwright install chromium --with-deps - # For PRs and pushes we spin up a minimal DreamFactory stack so the - # smoke specs run against a real API. The stack is provided by the - # df-docker-dev sibling repo checked out into the runner. - - name: Check out df-docker-dev - if: github.event_name == 'push' || github.event_name == 'pull_request' - uses: actions/checkout@v4 - with: - repository: dreamfactorysoftware/df-docker-dev - path: df-docker-dev - fetch-depth: 1 - - - name: Start DreamFactory stack - if: github.event_name == 'push' || github.event_name == 'pull_request' - working-directory: df-docker-dev - run: | - docker compose up -d web mysql redis - # wait for /system/environment to come back 200 - timeout 120 bash -c 'until curl -fsS http://localhost:8080/api/v2/system/environment > /dev/null; do sleep 2; done' - - name: Resolve target URL id: target run: | if [ -n "${{ github.event.inputs.target_url }}" ]; then echo "url=${{ github.event.inputs.target_url }}" >> "$GITHUB_OUTPUT" - elif [ "${{ github.event_name }}" = "schedule" ]; then - echo "url=${{ secrets.E2E_NIGHTLY_URL }}" >> "$GITHUB_OUTPUT" else - echo "url=http://localhost:8080" >> "$GITHUB_OUTPUT" + echo "url=${{ secrets.E2E_NIGHTLY_URL }}" >> "$GITHUB_OUTPUT" fi - name: Run Playwright tests diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 64b5c61d8..08e5b3a6d 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -25,7 +25,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' - run: npm ci - - run: npm run lint --if-present # `test:ci` runs only the specs that currently pass. The rest are # quarantined by jest.config.ci.js until the @ngneat/transloco ESM # migration is completed. New tests should be added to that config. From e8e77f350117ae350fc6dcb6c8bf5a3a3b55a7e5 Mon Sep 17 00:00:00 2001 From: nicdavidson Date: Wed, 22 Apr 2026 12:13:58 -0600 Subject: [PATCH 4/4] =?UTF-8?q?test(e2e):=20add=20discovery=20spec=20?= =?UTF-8?q?=E2=80=94=20reports=20nav=20automation=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not a regression gate. Runs five journeys (event-scripts, api-db, roles, admins, mcp) and reports what breaks. First run surfaces one cross-cutting finding more than any per-journey bug: the admin sidebar is resistant to standard Playwright selectors. Concrete observations from the first run (see console output): - clicking any sidebar nav-item button (even with force:true) lands at /home instead of the target route. Suggests (a) the click event isn't reaching the Angular router handler, (b) navigation happens but a guard / welcome flow redirects, or (c) click falls through to a nearby element (the "Admins" click landed at /ai, which sits close by in the DOM). - no clickable [routerLink] or href on the nav-item elements; Angular binds via (click) on wrappers. - actionability without force: true always times out. Fixing this right likely needs data-testid attributes on nav items or a dedicated NavPage object that understands the accordion state. Parking for now — ships the spec as a runnable record of the automation gap. --- e2e/_findings.spec.ts | 285 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 e2e/_findings.spec.ts diff --git a/e2e/_findings.spec.ts b/e2e/_findings.spec.ts new file mode 100644 index 000000000..48d4bdf44 --- /dev/null +++ b/e2e/_findings.spec.ts @@ -0,0 +1,285 @@ +/** + * Discovery run. Not a regression gate — this spec is designed to FAIL + * loudly on anything that isn't working so we get a concrete bug list. + * Do not add to jest.config.ci or the nightly Playwright workflow. + * + * Strategy: simulate a new admin working through the most common journeys + * on a vanilla dev-docker instance. Each step records what it saw; the + * final console report lists every deviation from expected. + */ +import { test, expect, Page } from '@playwright/test'; +import { loginAsAdmin, waitForAppReady } from './fixtures/admin-login'; + +type Finding = { + journey: string; + step: string; + detail: string; +}; +const findings: Finding[] = []; +function report(journey: string, step: string, detail: string) { + findings.push({ journey, step, detail }); + console.log(` [${journey}] ${step}: ${detail}`); +} + +async function clickNavByText( + page: Page, + label: string, + journey: string +): Promise { + // Sidebar entries are
wrappers with a