Skip to content

Commit 9a82bf5

Browse files
committed
E2E foundation complete: Playwright scaffold + core flow specs, API contracts, CI tighten, docs
- Add Playwright config/global setup already in repo; add browse.spec.ts and scan.spec.ts specs using stable data-testid hooks - Fix scan E2E assertions to avoid regex pitfalls and allow multiple occurrences - Add API contracts for /api/scan, /api/scans/<id>/status, /api/directories - Make E2E job strict in CI; document in docs/testing.md and add PR template - Align Dev CLI timestamps and finalize task:e2e:02-playwright-scaffold bookkeeping
1 parent 2bdf2e9 commit 9a82bf5

File tree

5 files changed

+185
-1
lines changed

5 files changed

+185
-1
lines changed

.github/pull_request_template.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
Title: E2E foundation, contracts, and CI
2+
3+
Summary
4+
- Implements Playwright E2E scaffold with smoke + core flow specs (browse, scan)
5+
- Adds minimal API contracts for /api/scan, /api/scans/<id>/status, /api/directories
6+
- Aligns Dev CLI timestamps to timezone-aware ISO8601
7+
- Adds stable data-testid hooks to UI templates
8+
- Wires CI for pytest and strict E2E smoke
9+
- Updates docs/testing.md with quickstarts and CI notes
10+
11+
Related
12+
- Story: story:e2e-testing
13+
- Phase: 02–03
14+
- Task(s):
15+
- task:e2e:02-playwright-scaffold (Done)
16+
- task:e2e:03-core-flows (partially addressed by initial specs; remaining flows in next branch)
17+
18+
Local verification
19+
- Python tests: python -m pytest -q → green
20+
- Playwright E2E: npm install && npx playwright install --with-deps && npm run e2e → green
21+
22+
CI
23+
- GitHub Actions workflow runs:
24+
- Python tests (3.11)
25+
- E2E smoke (Node 18) — strict, SCIDK_PROVIDERS=local_fs
26+
27+
How to run locally
28+
- API contracts: python -m pytest tests/contracts/test_api_contracts.py -q
29+
- E2E: npm run e2e (uses e2e/global-setup.ts to boot the Flask server)
30+
31+
Risk assessment
32+
- Low risk. Mostly additive tests/config/docs. UI changes limited to data-testid attributes.
33+
34+
Merge checklist
35+
- [x] pytest green locally
36+
- [x] E2E green locally
37+
- [x] CI expected to pass (strict E2E)
38+
- [x] task:e2e:02-playwright-scaffold marked Done with completed_at
39+
- [ ] Reviewer sanity pass on docs/testing.md and CI workflow
40+
41+
Post-merge (follow-up branch)
42+
- Expand Phase 03 core flows E2E and refine contracts as needed.

docs/testing.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,31 @@ CI (phase 05)
200200
- Python tests (pytest): sets up Python 3.11, installs deps (requirements.txt / pyproject), runs python -m pytest -q
201201
- E2E smoke (Playwright): sets up Node 18, installs deps, installs browsers with npx playwright install --with-deps, runs npm run e2e
202202
- Environment: SCIDK_PROVIDERS=local_fs is used during E2E to avoid external dependencies.
203-
- Continue-on-error: E2E job is marked continue-on-error: true during bring-up; tighten later when stable.
203+
- E2E job is strict (no continue-on-error) now that smoke and core flows are stable; monitor the first PR run and investigate any flakes.
204204
- To run the same locally:
205205
- python -m pytest -q
206206
- npm install && npx playwright install --with-deps && npm run e2e
207+
208+
209+
210+
Updates (Phase 03 prep)
211+
- New API contracts added under tests/contracts/test_api_contracts.py:
212+
- test_scan_contract_local_fs: POST /api/scan returns a payload with an id or ok.
213+
- test_scan_status_contract: GET /api/scans/<id>/status returns a dict with a status/state/done field.
214+
- test_directories_contract: GET /api/directories returns a list with items containing path.
215+
- New Playwright specs:
216+
- e2e/browse.spec.ts: navigates to Files and verifies stable hooks, no console errors.
217+
- e2e/scan.spec.ts: posts /api/scan for a temp directory and verifies the Home page lists it.
218+
219+
How to run the new tests
220+
- Contracts subset:
221+
- python -m pytest tests/contracts/test_api_contracts.py::test_scan_contract_local_fs -q
222+
- python -m pytest tests/contracts/test_api_contracts.py::test_scan_status_contract -q
223+
- python -m pytest tests/contracts/test_api_contracts.py::test_directories_contract -q
224+
- E2E specs:
225+
- npm run e2e # runs all specs including smoke, browse, scan
226+
- npm run e2e:headed # optional, debug mode
227+
228+
Notes
229+
- E2E relies on BASE_URL from global-setup (spawns Flask). SCIDK_PROVIDERS defaults to local_fs in CI.
230+
- The scan E2E uses a real temp directory under the runner OS temp path and triggers a synchronous scan via /api/scan.

e2e/browse.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Browse flow: navigate to Files and ensure stable hooks are present and no console errors
4+
5+
test('files page loads and shows stable hooks', async ({ page, baseURL }) => {
6+
const consoleMessages: { type: string; text: string }[] = [];
7+
page.on('console', (msg) => {
8+
consoleMessages.push({ type: msg.type(), text: msg.text() });
9+
});
10+
11+
const url = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000';
12+
await page.goto(url);
13+
14+
// Go to Files via stable nav hook
15+
await page.getByTestId('nav-files').click();
16+
17+
// Expect the Files page to render
18+
await expect(page.getByTestId('files-title')).toBeVisible();
19+
await expect(page.getByTestId('files-root')).toBeVisible();
20+
21+
// Let network settle and ensure no console errors
22+
await page.waitForLoadState('networkidle');
23+
const errors = consoleMessages.filter((m) => m.type === 'error');
24+
expect(errors.length).toBe(0);
25+
});

e2e/scan.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test, expect, request } from '@playwright/test';
2+
import os from 'os';
3+
import fs from 'fs';
4+
import path from 'path';
5+
6+
// Scan flow: create a small temp directory on the runner, POST /api/scan to index it,
7+
// then verify Home lists it under Scanned Sources.
8+
9+
function makeTempDirWithFile(prefix = 'scidk-e2e-'): string {
10+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
11+
fs.writeFileSync(path.join(dir, 'e2e.txt'), 'hello');
12+
return dir;
13+
}
14+
15+
test('scan a temp directory and verify it appears on Home', async ({ page, baseURL, request: pageRequest }) => {
16+
const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000';
17+
const tempDir = makeTempDirWithFile();
18+
19+
// Kick off a non-recursive scan via HTTP API (synchronous in current implementation)
20+
const api = pageRequest || (await request.newContext());
21+
const resp = await api.post(`${base}/api/scan`, {
22+
headers: { 'Content-Type': 'application/json' },
23+
data: { path: tempDir, recursive: false },
24+
});
25+
expect(resp.ok()).toBeTruthy();
26+
27+
// Navigate to Home and check that the scanned source is listed
28+
await page.goto(base);
29+
await page.waitForLoadState('domcontentloaded');
30+
await page.waitForLoadState('networkidle');
31+
32+
// The Home page shows a Scanned Sources list when directories exist.
33+
// Assert the tempDir path appears somewhere in the page. Use getByText to avoid regex parsing of slashes.
34+
const occurrences = await page.getByText(tempDir, { exact: false }).count();
35+
expect(occurrences).toBeGreaterThan(0);
36+
});

tests/contracts/test_api_contracts.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,60 @@ def test_browse_contract_local_fs_root_listing(client):
3333
for e in data['entries']:
3434
assert isinstance(e, dict)
3535
assert 'name' in e and 'type' in e
36+
37+
38+
39+
def test_scan_contract_local_fs(client, tmp_path):
40+
# Create a temporary file to ensure directory is non-empty
41+
p = tmp_path / 'sample.txt'
42+
p.write_text('hello', encoding='utf-8')
43+
r = client.post('/api/scan', json={'path': str(tmp_path), 'recursive': False})
44+
assert r.status_code == 200
45+
data = r.get_json()
46+
# legacy /api/scan may return a dict or minimal payload; accept either id or ok
47+
assert isinstance(data, dict)
48+
assert 'id' in data or 'ok' in data or 'scan_id' in data
49+
50+
51+
def test_scan_status_contract(client, tmp_path):
52+
# kick off a small scan
53+
(tmp_path / 'a.txt').write_text('x', encoding='utf-8')
54+
r = client.post('/api/scan', json={'path': str(tmp_path), 'recursive': False})
55+
assert r.status_code == 200
56+
payload = r.get_json() or {}
57+
scan_id = payload.get('id') or payload.get('scan_id') or payload.get('scanId')
58+
# If synchronous legacy scan, status may be available via last item in /api/scans
59+
if not scan_id:
60+
scans_resp = client.get('/api/scans')
61+
assert scans_resp.status_code == 200
62+
scans = scans_resp.get_json() or []
63+
assert isinstance(scans, list)
64+
if scans:
65+
scan_id = scans[-1].get('id')
66+
# If still missing, skip to keep contract minimal/non-flaky
67+
if not scan_id:
68+
pytest.skip('scan_id not available from API after legacy /api/scan response')
69+
st = client.get(f'/api/scans/{scan_id}/status')
70+
assert st.status_code == 200
71+
sd = st.get_json()
72+
assert isinstance(sd, dict)
73+
# Expect at least a status/state field
74+
assert any(k in sd for k in ('status', 'state', 'done'))
75+
76+
77+
def test_directories_contract(client, tmp_path):
78+
# Ensure at least one directory exists in the registry by running a quick scan
79+
(tmp_path / 'b.txt').write_text('y', encoding='utf-8')
80+
client.post('/api/scan', json={'path': str(tmp_path), 'recursive': False})
81+
d = client.get('/api/directories')
82+
assert d.status_code == 200
83+
arr = d.get_json()
84+
assert isinstance(arr, list)
85+
# Minimal shape
86+
if arr:
87+
for item in arr:
88+
assert isinstance(item, dict)
89+
assert 'path' in item
90+
# optional helpful fields
91+
_ = item.get('scanned') if isinstance(item, dict) else None
92+
_ = item.get('recursive') if isinstance(item, dict) else None

0 commit comments

Comments
 (0)