Data-layer BDD testing infrastructure and steps/frontend → steps/ui rename
- Rename steps/frontend/ to steps/ui/ across all modules and shared - Add data-layer test harness (mock + real broker modes) with Playwright - Add inscription data-layer steps (@data scenarios) - Add test auth setup script and browser debug script - Update docs (architecture, BDD testing, data-layer testing) - Add ADR for headless wallet creation decision Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,3 +33,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
.ng-tarballs
|
||||
|
||||
# Playwright persistent profile (contains NG wallet)
|
||||
.playwright-profile/
|
||||
.playwright-profile-debug/
|
||||
playwright/.auth/
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Automated Headless Wallet Creation for CI
|
||||
|
||||
**Date:** 2026-03-12 15:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Data-layer BDD tests (`@data` scenarios) require a NextGraph wallet in a persistent Chromium profile. Previously, the first run required manual interaction: a visible browser opened and the user had to create a wallet and close the browser. This blocked CI execution.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Programmatic wallet creation via NG SDK
|
||||
Call `ng.wallet_create()` directly from Node/Bun, bypassing the UI entirely.
|
||||
|
||||
**Arguments for:**
|
||||
- Fastest execution
|
||||
- No browser needed for wallet creation
|
||||
|
||||
**Arguments against:**
|
||||
- `@ng-org/web` is browser-only (WASM + postMessage)
|
||||
- Would need to reverse-engineer the registration API at `account.nextgraph.eu`
|
||||
- Doesn't test the real auth flow
|
||||
|
||||
### Option B: Automate the browser UI flow headlessly
|
||||
Use Playwright to drive the same wallet creation UI a real user would use, but in headless mode.
|
||||
|
||||
**Arguments for:**
|
||||
- Tests the real auth/login feature end-to-end
|
||||
- No API reverse-engineering needed
|
||||
- Same persistent profile used for subsequent test runs
|
||||
- CI-ready with no manual steps
|
||||
|
||||
**Arguments against:**
|
||||
- Depends on `nextgraph.eu` and `account.nextgraph.eu` being reachable
|
||||
- UI changes in NextGraph could break the automation
|
||||
- Adds ~27s to first run
|
||||
|
||||
## Decision
|
||||
|
||||
Option B — automate the browser UI. The wallet creation flow (navigate to `nextgraph.eu` → "Create Wallet" → accept ToS at `account.nextgraph.eu` → fill username/password → submit) is itself a legitimate test of the app's auth feature. The dependency on external services is acceptable since the tests already depend on the broker being reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Tests are fully CI-ready (no human interaction)
|
||||
- Auth/login flow is tested as a side effect
|
||||
- Single command `bun run test:data` works from a clean state
|
||||
|
||||
**Negative:**
|
||||
- Requires internet access (nextgraph.eu, account.nextgraph.eu)
|
||||
- Fragile to NextGraph UI changes (button text, form IDs)
|
||||
|
||||
**Risks:**
|
||||
- `account.nextgraph.eu` rate limiting could block CI runs that frequently recreate wallets
|
||||
@@ -18,7 +18,7 @@ src/modules/
|
||||
Each module can contain:
|
||||
- `screens/` — React screen components
|
||||
- `features/` — Gherkin `.feature` files (BDD specs)
|
||||
- `steps/{frontend,backend,e2e}/` — Cucumber step definitions by layer
|
||||
- `steps/{ui,data,e2e}/` — Cucumber step definitions by layer
|
||||
|
||||
## Import Rules
|
||||
|
||||
@@ -45,7 +45,7 @@ src/modules/event/screens/EventDetailScreen.tsx
|
||||
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
|
||||
| `shapes/` | SHEX definitions + ORM TypeScript bindings |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts` |
|
||||
| `steps/frontend/` | Shared BDD step definitions (navigation, screen, form) |
|
||||
| `steps/ui/` | Shared BDD step definitions (navigation, screen, form) |
|
||||
| `support/` | Cucumber `world.ts`, `hooks.ts` |
|
||||
| `types/` | `gherkin.ts` (ParsedFeature, ParsedScenario types) |
|
||||
| `lib/` | `utils.ts` (cn helper for Tailwind) |
|
||||
|
||||
@@ -15,12 +15,12 @@ Each module has step directories for three test layers:
|
||||
|
||||
```
|
||||
src/modules/event/steps/
|
||||
frontend/ # UI/screen assertions (active)
|
||||
backend/ # Data layer assertions (planned)
|
||||
ui/ # UI/screen assertions (active)
|
||||
data/ # Data layer assertions
|
||||
e2e/ # Full integration (planned)
|
||||
```
|
||||
|
||||
Shared steps (cross-domain) live in `src/shared/steps/frontend/`.
|
||||
Shared steps (cross-domain) live in `src/shared/steps/ui/`.
|
||||
|
||||
## Feature Files
|
||||
|
||||
@@ -37,7 +37,7 @@ Tagged with `@CATEGORY @priority-N` for filtering.
|
||||
|
||||
## Step Definitions
|
||||
|
||||
### Shared Steps (`src/shared/steps/frontend/`)
|
||||
### Shared Steps (`src/shared/steps/ui/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
@@ -45,7 +45,7 @@ Tagged with `@CATEGORY @priority-N` for filtering.
|
||||
| `form.steps.ts` | Form field validation, required fields, import/duplicate detection |
|
||||
| `screen.steps.ts` | Screen content assertions (participants, events, profiles, QR codes) |
|
||||
|
||||
### How Frontend Steps Work
|
||||
### How UI Steps Work
|
||||
|
||||
Steps analyze screen **source code** (not rendered DOM):
|
||||
1. `world.ts` loads screen `.tsx` file content via `loadScreenSource()`
|
||||
@@ -89,13 +89,17 @@ Scripts in `scripts/` parse features and steps into TypeScript data files consum
|
||||
|--------|-------|--------|
|
||||
| `parse-features.ts` | `src/modules/*/features/*.feature` | `src/shared/data/features.ts` |
|
||||
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
|
||||
| `extract-step-definitions.ts` | `src/shared/steps/frontend/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
| `extract-step-definitions.ts` | `src/shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
|
||||
Run all: `bun run test:cucumber`
|
||||
|
||||
## Data-Layer Testing
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker. See [data-layer-testing](./data-layer-testing.md) for full architecture.
|
||||
|
||||
## Adding New Steps
|
||||
|
||||
1. **Module-specific**: Create in `src/modules/{module}/steps/frontend/`
|
||||
2. **Cross-domain**: Add to `src/shared/steps/frontend/`
|
||||
1. **Module-specific**: Create in `src/modules/{module}/steps/ui/`
|
||||
2. **Cross-domain**: Add to `src/shared/steps/ui/`
|
||||
3. Import `FestipodWorld` type from `../../support/world` (shared) or adjust relative path
|
||||
4. Run `bun run steps:extract` to regenerate tooltip data
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# Data-Layer Testing
|
||||
|
||||
BDD scenarios tagged `@data` test the real NextGraph data pipeline through a broker, not mocked data.
|
||||
|
||||
## Overview
|
||||
|
||||
`@data` scenarios run Cucumber steps against a real NextGraph broker. Playwright drives a Chromium instance that authenticates with the broker, which loads our test harness in an iframe. The harness uses real `useShape`/ORM subscriptions and exposes a `window.__testData` bridge for step definitions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.eu/auth/#/?o=http://127.0.0.1:{port}
|
||||
↓
|
||||
Broker wallet login (automated)
|
||||
↓
|
||||
Broker loads app in iframe → http://127.0.0.1:{port}
|
||||
↓
|
||||
harness-ng.tsx (init → useShape → ORM → broker)
|
||||
↓
|
||||
window.__testData bridge
|
||||
```
|
||||
|
||||
## Dual Mode
|
||||
|
||||
- **Real broker** (default): `harness-ng.tsx` with NextGraph ORM through broker iframe
|
||||
- **Mock fallback**: `harness.tsx` with standalone DeepSignalSets (if NG harness build fails)
|
||||
|
||||
## Wallet Lifecycle
|
||||
|
||||
Fully automated — no manual interaction required. CI-ready.
|
||||
|
||||
### First Run (wallet creation)
|
||||
1. `BeforeAll` detects no `.wallet-ready` marker in `.playwright-profile/`
|
||||
2. Launches headless Chromium with persistent profile
|
||||
3. Navigates to `https://nextgraph.eu/` → clicks "Create Wallet"
|
||||
4. Redirected to `account.nextgraph.eu` → clicks "I accept" (ToS)
|
||||
5. Redirected back → fills username/password form → submits
|
||||
6. Wallet created in localStorage → marker written
|
||||
7. This is also a real test of the app's auth/login feature
|
||||
|
||||
### Subsequent Runs (automated login)
|
||||
1. Marker found → skip wallet creation
|
||||
2. Headless Chromium with persistent profile
|
||||
3. Automated login: click "Login" → click wallet link → fill password → submit
|
||||
4. Broker authenticates, loads app harness in iframe
|
||||
5. Harness initializes NG, creates ORM subscriptions, seeds data if needed
|
||||
6. `window.__testData.ready` → steps execute via `appFrame.evaluate()`
|
||||
|
||||
### Wallet Credentials
|
||||
- Name: `festipod-tests`
|
||||
- Password: `festipod-tests`
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Chromium Flags
|
||||
```
|
||||
--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,...
|
||||
--allow-insecure-localhost
|
||||
--disable-web-security
|
||||
```
|
||||
Required because broker at `nextgraph.eu` (public) loads harness from `http://127.0.0.1:{port}` (local) in an iframe — Chromium's Private Network Access blocks this by default.
|
||||
|
||||
### Persistent Profile (`.playwright-profile/`)
|
||||
- Stores NG wallet in localStorage (`ng_wallets` on `nextgraph.eu`, `ng_bootstrap` on `nextgraph.net`)
|
||||
- Gitignored
|
||||
- Must use full Chrome binary, not `chrome-headless-shell`
|
||||
|
||||
### HTTP Server
|
||||
- Started in `BeforeAll` on auto-assigned port (`127.0.0.1:0`)
|
||||
- Serves harness HTML at `/` and JS bundle at `/harness.js` (separate files — inline script breaks due to special characters in bundle)
|
||||
- Shut down in `AfterAll`
|
||||
|
||||
### ORM Subscriptions
|
||||
Harness creates subscriptions for all three shapes with scope `did:ng:i` (private store):
|
||||
- `FpEventShapeType` → events
|
||||
- `FpUserProfileShapeType` → users
|
||||
- `FpParticipationShapeType` → participations
|
||||
|
||||
### Test Bridge (`window.__testData`)
|
||||
Exposed by the harness, consumed by steps via `appFrame.evaluate()`:
|
||||
- `events`, `users`, `participations` — live DeepSignalSets
|
||||
- `currentUserId` — IRI of the test user
|
||||
- `getEvent(id)`, `getEventByTitle(title)` — lookups
|
||||
- `joinEvent(eventId, userId)`, `leaveEvent(eventId, userId)` — mutations
|
||||
- `isParticipating(eventId, userId)`, `getEventParticipants(eventId)` — queries
|
||||
- `updateEvent(eventId, updates)` — field updates
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/test-harness/harness-ng.tsx` | Real broker harness (useShape through broker iframe) |
|
||||
| `src/shared/test-harness/harness.tsx` | Mock harness (DeepSignalSets, no broker) |
|
||||
| `src/shared/support/hooks.ts` | Playwright lifecycle (wallet creation, login automation, iframe detection) |
|
||||
| `src/shared/support/world.ts` | World with `page`/`appFrame` fields |
|
||||
| `src/modules/event/steps/data/inscription.steps.ts` | Inscription data steps |
|
||||
| `.playwright-profile/` | Persistent Chromium profile (gitignored) |
|
||||
| `scripts/debug-browser.ts` | Manual browser debug tool — launches headed Chromium to inspect broker interactions |
|
||||
| `.playwright-profile-debug/` | Chromium profile created by debug-browser.ts (gitignored) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run test:data # Run @data scenarios (real broker if wallet exists, mock fallback)
|
||||
bun run test:cucumber # Run all scenarios (UI + data)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [BDD Testing](./bdd-testing.md) — general Cucumber setup, UI-layer steps
|
||||
- [Data Layer](./data-layer.md) — NextGraph stack, shapes, context providers
|
||||
@@ -28,6 +28,8 @@ NextGraph (P2P/local-first) with SHEX shapes and ORM. See [data-layer](.project/
|
||||
|
||||
Multi-layer Cucumber/Gherkin in French. See [bdd-testing](.project/knowledge/bdd-testing.md).
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker with Playwright. See [data-layer-testing](.project/knowledge/data-layer-testing.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
@@ -45,3 +47,4 @@ bun run build:orm # Regenerate ORM from SHEX shapes
|
||||
- [Data Layer](.project/knowledge/data-layer.md) — NextGraph, shapes, context, seed data
|
||||
- [BDD Testing](.project/knowledge/bdd-testing.md) — Cucumber setup, step layers, feature files
|
||||
- [Screens](.project/knowledge/screens.md) — screen inventory, registry, sketchy components
|
||||
- [Data-Layer Testing](.project/knowledge/data-layer-testing.md) — real broker testing, wallet setup, Playwright harness
|
||||
|
||||
@@ -10,7 +10,7 @@ This project has two parts:
|
||||
|
||||
Feature-based architecture: code is organized by business domain (module), not by technical layer. A module can only import from `shared/` — never from another module.
|
||||
|
||||
Multi-layer BDD: each module has `steps/frontend/`, `steps/backend/`, `steps/e2e/` directories. Shared step definitions live in `src/shared/steps/`.
|
||||
Multi-layer BDD: each module has `steps/ui/`, `steps/data/`, `steps/e2e/` directories. Shared step definitions live in `src/shared/steps/`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
@@ -21,8 +21,8 @@ src/
|
||||
screens/ # EventsScreen, EventDetailScreen, CreateEventScreen, etc.
|
||||
features/ # Gherkin .feature files for this domain
|
||||
steps/ # BDD step definitions
|
||||
frontend/ # Frontend-layer steps
|
||||
backend/ # Backend-layer steps (planned)
|
||||
ui/ # UI-layer steps
|
||||
data/ # Data-layer steps
|
||||
e2e/ # E2E steps (planned)
|
||||
user/ # User profiles, friends, sharing
|
||||
screens/ # ProfileScreen, FriendsListScreen, ShareProfileScreen, etc.
|
||||
@@ -51,8 +51,8 @@ src/
|
||||
shapes/ # SHEX shapes + ORM bindings (NextGraph)
|
||||
utils/ # ngSession, ngBootstrap
|
||||
steps/ # Shared BDD step definitions (cross-domain)
|
||||
frontend/ # navigation.steps.ts, form.steps.ts, screen.steps.ts
|
||||
backend/
|
||||
ui/ # navigation.steps.ts, form.steps.ts, screen.steps.ts
|
||||
data/
|
||||
support/ # Cucumber hooks.ts, world.ts
|
||||
types/ # TypeScript type definitions
|
||||
lib/ # Utility functions (cn, etc.)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
"dependencies": {
|
||||
"@ng-org/orm": ".ng-tarballs/ng-org-orm-0.1.2-alpha.15.tgz",
|
||||
"@ng-org/alien-deepsignals": ".ng-tarballs/ng-org-alien-deepsignals-0.1.2-alpha.11.tgz",
|
||||
"@ng-org/orm": ".ng-tarballs/ng-org-orm-0.1.2-alpha.15.tgz",
|
||||
"@ng-org/shex-orm": ".ng-tarballs/ng-org-shex-orm-0.1.2-alpha.7.tgz",
|
||||
"@ng-org/web": ".ng-tarballs/ng-org-web-0.1.2-alpha.11.tgz",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -382,7 +383,7 @@
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
@@ -512,6 +513,10 @@
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
||||
@@ -728,6 +733,8 @@
|
||||
|
||||
"then-request/@types/node": ["@types/node@8.10.66", "", {}, "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"yup/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
|
||||
|
||||
+4
-1
@@ -4,11 +4,13 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "portless run bun --hot src/index.ts",
|
||||
"dev": "portless festipod bun --hot src/index.ts",
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
@@ -42,6 +44,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+324
-9820
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Manual debug tool: launches a headed Chromium to inspect NextGraph broker interactions.
|
||||
* Creates a temporary profile in `.playwright-profile-debug/` (gitignored).
|
||||
*
|
||||
* Usage: bun scripts/debug-browser.ts
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
console.log('Browser launched. Navigating to nextgraph.net...');
|
||||
|
||||
page.on('pageerror', (err) => console.log('[pageerror]', err.message));
|
||||
page.on('close', () => console.log('[page closed]'));
|
||||
page.on('crash', () => console.log('[page crashed]'));
|
||||
browser.on('disconnected', () => console.log('[browser disconnected]'));
|
||||
|
||||
try {
|
||||
await page.goto('https://nextgraph.net/redir/#/?o=http%3A%2F%2F127.0.0.1%3A12345', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
});
|
||||
console.log('Navigation done. Page URL:', page.url());
|
||||
} catch (err: any) {
|
||||
console.log('Navigation error (expected):', err.message);
|
||||
}
|
||||
|
||||
console.log('Waiting... close the browser manually when done.');
|
||||
|
||||
// Keep alive indefinitely
|
||||
await new Promise(() => {});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* One-time auth setup for data-layer testing.
|
||||
*
|
||||
* Two-step flow:
|
||||
* Step 1: Opens nextgraph.net so you can create/import a wallet
|
||||
* Step 2: Navigates to the broker redirect URL to authenticate the test app
|
||||
*
|
||||
* Saves auth state to playwright/.auth/ng-state.json for reuse by test runs.
|
||||
*
|
||||
* Usage: bun run test:auth-setup
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import * as http from 'http';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as readline from 'readline';
|
||||
|
||||
const AUTH_STATE_PATH = path.resolve('playwright/.auth/ng-state.json');
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SETUP_HTML = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Festipod Test Auth Setup</title></head>
|
||||
<body style="font-family: system-ui; padding: 2rem; text-align: center;">
|
||||
<h2>Festipod — Test Auth Setup</h2>
|
||||
<p id="status">Waiting for NextGraph session...</p>
|
||||
<script type="module">
|
||||
import { init, ng } from "@ng-org/web";
|
||||
|
||||
await init(
|
||||
async (event) => {
|
||||
document.getElementById("status").innerHTML =
|
||||
'<span style="color: green; font-size: 1.5rem;">✓ Logged in!</span>' +
|
||||
'<br><br>You can now close this browser window.';
|
||||
console.log("[auth-setup] Session established:", event.session?.session_id);
|
||||
},
|
||||
true,
|
||||
[]
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
async function main() {
|
||||
console.log('=== NextGraph Auth Setup (2 steps) ===');
|
||||
console.log('');
|
||||
|
||||
// ---- Step 1: Create / open wallet ----
|
||||
console.log('STEP 1: Create or open your NextGraph wallet');
|
||||
console.log('A browser will open at nextgraph.net.');
|
||||
console.log('Create a wallet (or open an existing one), then come back here.');
|
||||
console.log('');
|
||||
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto('https://nextgraph.net', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await prompt('Press ENTER here once your wallet is ready...');
|
||||
|
||||
// ---- Step 2: Authenticate the test app via broker redirect ----
|
||||
console.log('');
|
||||
console.log('STEP 2: Authenticating test app via broker...');
|
||||
|
||||
// Start a minimal HTTP server
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(SETUP_HTML);
|
||||
});
|
||||
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve((server.address() as { port: number }).port);
|
||||
});
|
||||
});
|
||||
|
||||
const appUrl = `http://127.0.0.1:${port}`;
|
||||
const brokerUrl = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||||
|
||||
// Navigate the same page to the broker redirect
|
||||
await page.goto(brokerUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
console.log('The broker should now show your wallet login.');
|
||||
console.log('Log in, wait for "✓ Logged in!", then close the browser.');
|
||||
console.log('');
|
||||
|
||||
// Wait for browser close
|
||||
await new Promise<void>((resolve) => {
|
||||
page.on('close', () => resolve());
|
||||
browser.on('disconnected', () => resolve());
|
||||
});
|
||||
|
||||
// Save storage state
|
||||
try {
|
||||
const state = await context.storageState();
|
||||
fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });
|
||||
fs.writeFileSync(AUTH_STATE_PATH, JSON.stringify(state, null, 2));
|
||||
console.log(`\nAuth state saved to: ${AUTH_STATE_PATH}`);
|
||||
console.log('You can now run: bun run test:data');
|
||||
} catch {
|
||||
console.log('\nCould not save auth state — browser may have closed too quickly.');
|
||||
}
|
||||
|
||||
try { await context.close(); } catch {}
|
||||
try { await browser.close(); } catch {}
|
||||
server.close();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -9,22 +9,56 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Contexte:
|
||||
Étant donné que je suis connecté en tant qu'utilisateur
|
||||
|
||||
# --- UI ---
|
||||
|
||||
Scénario: Consulter un événement avant inscription
|
||||
Étant donné que je suis sur la page "détail événement"
|
||||
Alors l'écran affiche les informations de l'événement
|
||||
|
||||
Scénario: S'inscrire à un événement
|
||||
* Scénario non implémenté
|
||||
|
||||
Scénario: Se désinscrire d'un événement
|
||||
* Scénario non implémenté
|
||||
Scénario: Voir le bouton d'inscription sur l'écran
|
||||
Étant donné que je suis sur la page "détail événement"
|
||||
Alors je peux m'inscrire à l'événement
|
||||
|
||||
Scénario: Rechercher un événement existant
|
||||
Étant donné que je suis sur la page "découvrir"
|
||||
Alors je peux voir la liste des événements
|
||||
|
||||
Scénario: Vérifier les données de l'écran
|
||||
* Scénario non implémenté
|
||||
# --- Data ---
|
||||
|
||||
Scénario: Rechercher dans une base existante (Mobilizon)
|
||||
* Scénario non implémenté
|
||||
@data
|
||||
Scénario: S'inscrire à un événement
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors l'utilisateur est participant de l'événement "Formation CNV"
|
||||
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 9 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 11 participants
|
||||
|
||||
@data
|
||||
Scénario: L'inscription est idempotente
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
|
||||
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 12 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Formation CNV"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 8 participants
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Data-layer steps: operate via Playwright + window.__testData bridge.
|
||||
// The harness exposes DeepSignalSets and helper methods directly.
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const exists = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
return [...td.events].some((e: any) => e.title === title);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(exists, `Event "${eventTitle}" should exist in the data layer`).to.be.true;
|
||||
});
|
||||
|
||||
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
|
||||
await this.appFrame!.evaluate(
|
||||
([title, c]: [string, number]) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.updateEvent(event['@id'], { participantCount: c });
|
||||
},
|
||||
[eventTitle, count] as [string, number],
|
||||
);
|
||||
});
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.leaveEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (event) td.joinEvent(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const participating = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.isParticipating(event['@id'], td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
return event?.participantCount ?? -1;
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
|
||||
});
|
||||
|
||||
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
|
||||
});
|
||||
|
||||
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const found = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return false;
|
||||
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(found, `User should NOT appear in participants of "${eventTitle}"`).to.be.false;
|
||||
});
|
||||
|
||||
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
|
||||
const count = await this.appFrame!.evaluate(
|
||||
(title) => {
|
||||
const td = (window as any).__testData;
|
||||
const event = [...td.events].find((e: any) => e.title === title);
|
||||
if (!event) return 0;
|
||||
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
|
||||
},
|
||||
eventTitle,
|
||||
);
|
||||
expect(count, 'User should have exactly one participation record').to.equal(1);
|
||||
});
|
||||
+134
-12
@@ -134,14 +134,18 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "S'inscrire à un événement",
|
||||
"name": "Voir le bouton d'inscription sur l'écran",
|
||||
"tags": [],
|
||||
"steps": []
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"tags": [],
|
||||
"steps": []
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Étant donné que ",
|
||||
"text": "je suis sur la page \"détail événement\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "je peux m'inscrire à l'événement"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rechercher un événement existant",
|
||||
@@ -158,18 +162,136 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Vérifier les données de l'écran",
|
||||
"name": "S'inscrire à un événement",
|
||||
"tags": [],
|
||||
"steps": []
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Étant donné",
|
||||
"text": "un événement \"Formation CNV\" existe"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Formation CNV\" a 8 participants au départ"
|
||||
},
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur s'inscrit à l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'utilisateur est participant de l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur apparaît dans la liste des participants de l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Formation CNV\" compte 9 participants"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Rechercher dans une base existante (Mobilizon)",
|
||||
"name": "Se désinscrire d'un événement",
|
||||
"tags": [],
|
||||
"steps": []
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Étant donné",
|
||||
"text": "un événement \"Résidence Reconnexion\" existe"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Résidence Reconnexion\" a 12 participants au départ"
|
||||
},
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur se désinscrit de l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'utilisateur n'est plus participant de l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur n'apparaît plus dans la liste des participants de l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Résidence Reconnexion\" compte 11 participants"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "L'inscription est idempotente",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Étant donné",
|
||||
"text": "un événement \"Résidence Reconnexion\" existe"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Résidence Reconnexion\" a 12 participants au départ"
|
||||
},
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur essaie de s'inscrire une seconde fois à l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'inscription est idempotente pour l'événement \"Résidence Reconnexion\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Résidence Reconnexion\" compte 12 participants"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Se désinscrire d'un événement auquel on n'est pas inscrit",
|
||||
"tags": [],
|
||||
"steps": [
|
||||
{
|
||||
"keyword": "Étant donné",
|
||||
"text": "un événement \"Formation CNV\" existe"
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Formation CNV\" a 8 participants au départ"
|
||||
},
|
||||
{
|
||||
"keyword": "Quand",
|
||||
"text": "l'utilisateur se désinscrit de l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Alors",
|
||||
"text": "l'utilisateur n'est plus participant de l'événement \"Formation CNV\""
|
||||
},
|
||||
{
|
||||
"keyword": "Et",
|
||||
"text": "l'événement \"Formation CNV\" compte 8 participants"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"filePath": "src/modules/event/features/us-7-inscription-evenement.feature",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1\nFonctionnalité: US-7 M'inscrire/me désinscrire à un événement\n En tant qu'utilisateur\n Je peux m'inscrire/me désinscrire à un événement\n Après avoir consulté la description de l'événement, les dates et le lieu\n S'il existe déjà dans le système ou en le retrouvant dans une base existante\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n Scénario: Consulter un événement avant inscription\n Étant donné que je suis sur la page \"détail événement\"\n Alors l'écran affiche les informations de l'événement\n\n Scénario: S'inscrire à un événement\n * Scénario non implémenté\n\n Scénario: Se désinscrire d'un événement\n * Scénario non implémenté\n\n Scénario: Rechercher un événement existant\n Étant donné que je suis sur la page \"découvrir\"\n Alors je peux voir la liste des événements\n\n Scénario: Vérifier les données de l'écran\n * Scénario non implémenté\n\n Scénario: Rechercher dans une base existante (Mobilizon)\n * Scénario non implémenté\n",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1\nFonctionnalité: US-7 M'inscrire/me désinscrire à un événement\n En tant qu'utilisateur\n Je peux m'inscrire/me désinscrire à un événement\n Après avoir consulté la description de l'événement, les dates et le lieu\n S'il existe déjà dans le système ou en le retrouvant dans une base existante\n\n Contexte:\n Étant donné que je suis connecté en tant qu'utilisateur\n\n # --- UI ---\n\n Scénario: Consulter un événement avant inscription\n Étant donné que je suis sur la page \"détail événement\"\n Alors l'écran affiche les informations de l'événement\n\n Scénario: Voir le bouton d'inscription sur l'écran\n Étant donné que je suis sur la page \"détail événement\"\n Alors je peux m'inscrire à l'événement\n\n Scénario: Rechercher un événement existant\n Étant donné que je suis sur la page \"découvrir\"\n Alors je peux voir la liste des événements\n\n # --- Data ---\n\n @data\n Scénario: S'inscrire à un événement\n Étant donné un événement \"Formation CNV\" existe\n Et l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" a 8 participants au départ\n Quand l'utilisateur s'inscrit à l'événement \"Formation CNV\"\n Alors l'utilisateur est participant de l'événement \"Formation CNV\"\n Et l'utilisateur apparaît dans la liste des participants de l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" compte 9 participants\n\n @data\n Scénario: Se désinscrire d'un événement\n Étant donné un événement \"Résidence Reconnexion\" existe\n Et l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" a 12 participants au départ\n Quand l'utilisateur se désinscrit de l'événement \"Résidence Reconnexion\"\n Alors l'utilisateur n'est plus participant de l'événement \"Résidence Reconnexion\"\n Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" compte 11 participants\n\n @data\n Scénario: L'inscription est idempotente\n Étant donné un événement \"Résidence Reconnexion\" existe\n Et l'utilisateur est inscrit à l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" a 12 participants au départ\n Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement \"Résidence Reconnexion\"\n Alors l'inscription est idempotente pour l'événement \"Résidence Reconnexion\"\n Et l'événement \"Résidence Reconnexion\" compte 12 participants\n\n @data\n Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit\n Étant donné un événement \"Formation CNV\" existe\n Et l'utilisateur n'est pas inscrit à l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" a 8 participants au départ\n Quand l'utilisateur se désinscrit de l'événement \"Formation CNV\"\n Alors l'utilisateur n'est plus participant de l'événement \"Formation CNV\"\n Et l'événement \"Formation CNV\" compte 8 participants\n",
|
||||
"screenIds": [
|
||||
"event-detail",
|
||||
"events"
|
||||
|
||||
+282
-8
@@ -1,11 +1,181 @@
|
||||
import { Before, After, BeforeAll, AfterAll, Status } from '@cucumber/cucumber';
|
||||
import { Before, After, BeforeAll, AfterAll, Status, setDefaultTimeout } from '@cucumber/cucumber';
|
||||
import { chromium, type Browser, type BrowserContext } from 'playwright';
|
||||
import { execSync } from 'child_process';
|
||||
import * as http from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { FestipodWorld } from './world';
|
||||
|
||||
BeforeAll(async function () {
|
||||
setDefaultTimeout(30000);
|
||||
|
||||
let browser: Browser;
|
||||
let browserContext: BrowserContext;
|
||||
|
||||
// Harness paths
|
||||
const HARNESS_ENTRY = 'src/shared/test-harness/harness.tsx';
|
||||
const HARNESS_OUT = path.join('dist', 'test-harness.js');
|
||||
const HARNESS_NG_ENTRY = 'src/shared/test-harness/harness-ng.tsx';
|
||||
const HARNESS_NG_OUT = path.join('dist', 'test-harness-ng.js');
|
||||
// Persistent Chromium profile for NG wallet (not the user's daily browser)
|
||||
const PLAYWRIGHT_PROFILE = path.resolve('.playwright-profile');
|
||||
|
||||
let harnessServer: http.Server | null = null;
|
||||
let harnessPort = 0;
|
||||
let useRealBroker = false;
|
||||
|
||||
const WALLET_NAME = 'festipod-tests';
|
||||
const WALLET_PASSWORD = 'festipod-tests';
|
||||
|
||||
/**
|
||||
* Automated wallet creation + login on nextgraph.eu.
|
||||
* Flow:
|
||||
* 1. Navigate to https://nextgraph.eu/ → "Create Wallet" button
|
||||
* 2. Click → redirects to account.nextgraph.eu → "I accept" (ToS)
|
||||
* 3. Click → redirects back → username/password form → fill & submit
|
||||
* 4. Wallet created → saved in localStorage (shared with nextgraph.eu/auth/)
|
||||
*
|
||||
* This is also a legitimate test of the app's auth/login feature.
|
||||
*/
|
||||
async function ensureAuth(): Promise<void> {
|
||||
const markerPath = path.join(PLAYWRIGHT_PROFILE, '.wallet-ready');
|
||||
if (fs.existsSync(markerPath)) {
|
||||
console.log('[Auth] Wallet found in persistent profile — skipping creation');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Auth] No wallet — creating one automatically on nextgraph.eu...');
|
||||
fs.mkdirSync(PLAYWRIGHT_PROFILE, { recursive: true });
|
||||
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
|
||||
const authContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
|
||||
headless: true,
|
||||
executablePath: chromePath.includes('headless') ? undefined : chromePath,
|
||||
});
|
||||
const page = authContext.pages()[0] || await authContext.newPage();
|
||||
page.on('pageerror', () => {});
|
||||
|
||||
try {
|
||||
// Step 1: Navigate to nextgraph.eu — shows NoWallet page
|
||||
console.log('[Auth] Step 1: Loading nextgraph.eu...');
|
||||
await page.goto('https://nextgraph.eu/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
// Step 2: Click "Create Wallet"
|
||||
console.log('[Auth] Step 2: Clicking "Create Wallet"...');
|
||||
const createButton = page.getByText('Create Wallet', { exact: true });
|
||||
await createButton.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await createButton.click();
|
||||
|
||||
// Step 3: Redirects to account.nextgraph.eu — accept Terms of Service
|
||||
console.log('[Auth] Step 3: Accepting Terms of Service...');
|
||||
await page.waitForURL('**/account*', { timeout: 15000 }).catch(() => {});
|
||||
const acceptButton = page.getByText('I accept', { exact: true });
|
||||
await acceptButton.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await acceptButton.click();
|
||||
|
||||
// Step 4: Redirects back to nextgraph.eu (possibly via nextgraph.net for bootstrap)
|
||||
// Wait for the username input to appear (wallet creation form)
|
||||
console.log('[Auth] Step 4: Filling wallet credentials...');
|
||||
const usernameInput = page.locator('#username-input');
|
||||
await usernameInput.waitFor({ state: 'visible', timeout: 30000 });
|
||||
await usernameInput.fill(WALLET_NAME);
|
||||
|
||||
const passwordInput = page.locator('#password-input');
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
|
||||
// Step 5: Submit — click the create button
|
||||
console.log('[Auth] Step 5: Creating wallet...');
|
||||
// The button text is "I create my wallet now!" — use a partial match
|
||||
const submitButton = page.getByText('create my wallet', { exact: false });
|
||||
await submitButton.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await submitButton.click();
|
||||
|
||||
// Step 6: Wait for wallet to be created (redirects to #/wallet/login)
|
||||
console.log('[Auth] Step 6: Waiting for wallet creation to complete...');
|
||||
await page.waitForURL('**/#/wallet/login', { timeout: 30000 });
|
||||
// Give localStorage time to persist
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
console.log('[Auth] Wallet created successfully');
|
||||
} finally {
|
||||
await authContext.close();
|
||||
}
|
||||
|
||||
fs.writeFileSync(markerPath, new Date().toISOString());
|
||||
console.log('[Auth] Wallet setup complete');
|
||||
}
|
||||
|
||||
// 10-minute timeout for BeforeAll: wallet creation may take a while on first run
|
||||
BeforeAll({ timeout: 10 * 60 * 1000 }, async function () {
|
||||
console.log('Starting Festipod BDD tests...');
|
||||
|
||||
// Build the mock harness (always needed for non-@data or fallback)
|
||||
execSync(`bun build ${HARNESS_ENTRY} --outfile ${HARNESS_OUT} --bundle`, { stdio: 'pipe' });
|
||||
console.log(`[Harness] Built mock (${(fs.statSync(HARNESS_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
|
||||
// Try to build the real broker harness
|
||||
try {
|
||||
execSync(`bun build ${HARNESS_NG_ENTRY} --outfile ${HARNESS_NG_OUT} --bundle`, { stdio: 'pipe' });
|
||||
console.log(`[Harness] Built NG (${(fs.statSync(HARNESS_NG_OUT).size / 1024).toFixed(0)} KB)`);
|
||||
|
||||
// Ensure wallet exists in persistent profile (opens browser if needed)
|
||||
await ensureAuth();
|
||||
useRealBroker = true;
|
||||
|
||||
// Start HTTP server serving the harness HTML + JS separately
|
||||
// (inline script breaks due to special characters in the bundle)
|
||||
const harnessBundle = fs.readFileSync(path.resolve(HARNESS_NG_OUT), 'utf-8');
|
||||
const harnessHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/harness.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
harnessServer = http.createServer((req, res) => {
|
||||
if (req.url === '/harness.js') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||||
res.end(harnessBundle);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(harnessHtml);
|
||||
}
|
||||
});
|
||||
harnessPort = await new Promise<number>((resolve) => {
|
||||
harnessServer!.listen(0, '127.0.0.1', () => {
|
||||
resolve((harnessServer!.address() as { port: number }).port);
|
||||
});
|
||||
});
|
||||
console.log(`[Harness] HTTP server on http://127.0.0.1:${harnessPort}`);
|
||||
|
||||
// Launch Chromium with the same persistent profile (has the wallet).
|
||||
// - Use full Chrome binary (not chrome-headless-shell) so localStorage persists
|
||||
// - Grant permissions to avoid prompts
|
||||
// - Disable Private Network Access (broker at nextgraph.eu needs to load
|
||||
// our local harness at http://127.0.0.1:{port} in an iframe)
|
||||
const chromePath = chromium.executablePath().replace('chrome-headless-shell', 'chrome').replace('chromium_headless_shell', 'chromium');
|
||||
browserContext = await chromium.launchPersistentContext(PLAYWRIGHT_PROFILE, {
|
||||
headless: true,
|
||||
executablePath: chromePath.includes('headless') ? undefined : chromePath,
|
||||
permissions: ['notifications', 'clipboard-read', 'clipboard-write', 'geolocation'],
|
||||
args: [
|
||||
'--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations',
|
||||
'--allow-insecure-localhost',
|
||||
'--disable-web-security',
|
||||
],
|
||||
});
|
||||
console.log('[Hooks] Real broker mode ready');
|
||||
} catch (err) {
|
||||
console.warn(`[Hooks] NG harness build/auth failed, falling back to mock: ${err}`);
|
||||
useRealBroker = false;
|
||||
browser = await chromium.launch({ headless: true });
|
||||
browserContext = await browser.newContext();
|
||||
console.log('[Hooks] Mock mode (no broker)');
|
||||
}
|
||||
});
|
||||
|
||||
Before(async function (this: FestipodWorld, scenario) {
|
||||
Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// Reset UI-layer state
|
||||
this.currentRoute = '#/';
|
||||
this.currentScreenId = null;
|
||||
this.formFields.clear();
|
||||
@@ -14,25 +184,129 @@ Before(async function (this: FestipodWorld, scenario) {
|
||||
this.screenSourceContent = '';
|
||||
this.currentScreen = null;
|
||||
|
||||
// Skipped scenarios use the "* Scénario non implémenté" placeholder step
|
||||
// which returns 'skipped' - no special handling needed in the hook
|
||||
// Launch Playwright page for @data scenarios
|
||||
const tags = scenario.pickle.tags.map(t => t.name);
|
||||
if (tags.includes('@data')) {
|
||||
this.page = await browserContext.newPage();
|
||||
|
||||
// Capture console for debugging
|
||||
this.page.on('pageerror', (err) => console.error('[Browser error]', err.message));
|
||||
this.page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') console.error('[Browser console]', msg.text());
|
||||
});
|
||||
|
||||
if (useRealBroker) {
|
||||
// Navigate to broker redirect — broker loads our harness in an iframe
|
||||
const appUrl = `http://127.0.0.1:${harnessPort}`;
|
||||
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
|
||||
|
||||
await this.page.goto(brokerRedirect, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Automate wallet login if needed
|
||||
const loginButton = this.page.getByText('Login', { exact: true });
|
||||
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await loginButton.click();
|
||||
await this.page.waitForURL('**/wallet/login', { timeout: 5000 }).catch(() => {});
|
||||
|
||||
const walletLink = this.page.getByText('Click here to login with your wallet');
|
||||
await walletLink.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await walletLink.click();
|
||||
await this.page.waitForTimeout(1000);
|
||||
|
||||
const passwordInput = this.page.locator('input[type="password"]');
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await passwordInput.fill(WALLET_PASSWORD);
|
||||
await passwordInput.press('Enter');
|
||||
|
||||
// Wait for login to complete and app iframe to load
|
||||
await this.page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
// Verify iframe loaded after login
|
||||
const iframeCount = await this.page.locator('iframe').count();
|
||||
if (iframeCount === 0) {
|
||||
const bodyText = await this.page.evaluate(() => document.body?.innerText?.substring(0, 300));
|
||||
throw new Error(`No iframe found after login. Body: ${bodyText}`);
|
||||
}
|
||||
|
||||
// Wait for our app iframe (broker loads it after successful auth)
|
||||
let appFrame = null;
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const f of this.page.frames()) {
|
||||
if (f.url().startsWith(appUrl) || f.url().includes('127.0.0.1')) {
|
||||
appFrame = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (appFrame) break;
|
||||
|
||||
for (const iframe of await this.page.locator('iframe').all()) {
|
||||
const src = await iframe.getAttribute('src');
|
||||
if (src && src.includes('127.0.0.1')) {
|
||||
const el = await iframe.elementHandle();
|
||||
appFrame = await el?.contentFrame();
|
||||
if (appFrame) break;
|
||||
}
|
||||
}
|
||||
if (appFrame) break;
|
||||
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
if (!appFrame) {
|
||||
const frames = this.page.frames().map(f => f.url());
|
||||
throw new Error(`App iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
|
||||
}
|
||||
|
||||
this.appFrame = appFrame;
|
||||
|
||||
// Wait for NG session + useShape + bridge
|
||||
await this.appFrame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
} else {
|
||||
// Mock mode: load harness directly
|
||||
await this.page.setContent('<!DOCTYPE html><html><body><div id="root"></div></body></html>');
|
||||
await this.page.addScriptTag({ path: path.resolve(HARNESS_OUT) });
|
||||
|
||||
this.appFrame = this.page.mainFrame();
|
||||
await this.appFrame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
After(async function (this: FestipodWorld, scenario) {
|
||||
After({ timeout: 10000 }, async function (this: FestipodWorld, scenario) {
|
||||
if (scenario.result?.status === Status.FAILED) {
|
||||
this.attach(`Current route: ${this.currentRoute}`, 'text/plain');
|
||||
this.attach(`Current screen: ${this.currentScreenId}`, 'text/plain');
|
||||
this.attach(`Navigation history: ${JSON.stringify(this.navigationHistory)}`, 'text/plain');
|
||||
this.attach(`Form fields: ${JSON.stringify(Array.from(this.formFields.entries()))}`, 'text/plain');
|
||||
if (this.screenSourceContent) {
|
||||
// Show first 500 chars of source to help debug
|
||||
this.attach(`Screen source (first 500 chars): ${this.screenSourceContent.substring(0, 500)}...`, 'text/plain');
|
||||
}
|
||||
}
|
||||
// Clean up
|
||||
|
||||
// Close Playwright page
|
||||
if (this.page) {
|
||||
await this.page.close();
|
||||
this.page = null;
|
||||
this.appFrame = null;
|
||||
}
|
||||
|
||||
// Clean up UI-layer
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
AfterAll(async function () {
|
||||
if (browserContext) await browserContext.close();
|
||||
if (browser) await browser.close();
|
||||
if (harnessServer) {
|
||||
await new Promise<void>((resolve) => harnessServer!.close(() => resolve()));
|
||||
}
|
||||
console.log('Festipod BDD tests completed.');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { World, setWorldConstructor, type IWorldOptions } from '@cucumber/cucumber';
|
||||
import { getScreen, type Screen } from '../../screens/index';
|
||||
import type { Page, Frame } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -10,10 +11,14 @@ export interface FestipodWorld extends World {
|
||||
navigationHistory: string[];
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Screen analysis
|
||||
// Screen analysis (UI layer)
|
||||
currentScreen: Screen | null;
|
||||
screenSourceContent: string;
|
||||
|
||||
// Playwright (data layer)
|
||||
page: Page | null;
|
||||
appFrame: Frame | null;
|
||||
|
||||
navigateTo(route: string): void;
|
||||
getFormField(name: string): { required: boolean; value: string } | undefined;
|
||||
getCurrentScreenFields(): string[];
|
||||
@@ -224,6 +229,10 @@ class CustomWorld extends World implements FestipodWorld {
|
||||
currentScreen: Screen | null = null;
|
||||
screenSourceContent: string = '';
|
||||
|
||||
// Playwright (data layer testing)
|
||||
page: Page | null = null;
|
||||
appFrame: Frame | null = null;
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Minimal NG app for auth setup.
|
||||
* Calls @ng-org/web init() — which redirects to broker if at top level,
|
||||
* or establishes session if inside broker iframe.
|
||||
* On success, POSTs the session back to the local server and saves it in localStorage.
|
||||
*/
|
||||
import { init, ng } from '@ng-org/web';
|
||||
|
||||
await init(
|
||||
async (event: any) => {
|
||||
const status = document.getElementById('status')!;
|
||||
status.innerHTML =
|
||||
'<span style="color: green; font-size: 1.5rem;">✓ Logged in!</span>' +
|
||||
'<br><br>You can close this tab. Tests will continue automatically.';
|
||||
|
||||
// Send session to local server so the test runner can save it
|
||||
const session = event.session;
|
||||
try {
|
||||
await fetch('/auth-done', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: session.session_id,
|
||||
private_store_id: session.private_store_id,
|
||||
protected_store_id: session.protected_store_id,
|
||||
public_store_id: session.public_store_id,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[auth-setup] Failed to notify server:', e);
|
||||
}
|
||||
},
|
||||
true,
|
||||
[],
|
||||
);
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Data-layer test harness (real NextGraph broker mode).
|
||||
*
|
||||
* Runs inside an iframe controlled by the NextGraph broker.
|
||||
* Uses real @ng-org/web init + @ng-org/orm useShape to test
|
||||
* the full data pipeline up to the broker.
|
||||
*
|
||||
* Exposes window.__testData for Playwright-driven Cucumber steps.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ng, init as initNgWeb } from '@ng-org/web';
|
||||
import { initNg as initNgSignals } from '@ng-org/orm';
|
||||
import { useShape } from '@ng-org/orm/react';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import {
|
||||
FpEventShapeType,
|
||||
FpUserProfileShapeType,
|
||||
FpParticipationShapeType,
|
||||
} from '../shapes/orm/festipodShapes.shapeTypes';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
|
||||
// ============================================================================
|
||||
// Seed data — injected into the broker if the graph is empty
|
||||
// ============================================================================
|
||||
|
||||
const SEED_EVENTS: Array<Omit<FpEvent, '@graph' | '@id'>> = [
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Résidence Reconnexion', date: 'Lun. 16 - Ven. 20 fév.', location: 'Écocentre de Villarceaux', participantCount: 24 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Marché des créateurs', date: 'Sam. 22 fév. · 10h', location: 'Place Bellecour, Lyon', participantCount: 12 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Cercle de parole', date: 'Dim. 23 fév. · 14h', location: 'Maison des associations', participantCount: 45 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Formation CNV', date: 'Sam. 1 mars · 9h30', location: 'Centre Iris, Paris', participantCount: 16 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Festival Printemps', date: 'Ven. 14 - Dim. 16 mars', location: 'Domaine de Longchamp', participantCount: 30 },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// NG initialization
|
||||
// ============================================================================
|
||||
|
||||
let sessionReady = false;
|
||||
let ngSession: any = null;
|
||||
|
||||
async function initNG() {
|
||||
console.log('[HarnessNG] Initializing NextGraph...');
|
||||
await initNgWeb(
|
||||
async (event: any) => {
|
||||
ngSession = event.session;
|
||||
ngSession.ng ??= ng;
|
||||
initNgSignals(ng, ngSession);
|
||||
sessionReady = true;
|
||||
console.log('[HarnessNG] NG session established, private_store_id:', ngSession.private_store_id);
|
||||
},
|
||||
true, // singleton
|
||||
[], // access_requests
|
||||
);
|
||||
}
|
||||
|
||||
// Start init immediately (this will postMessage to parent broker)
|
||||
initNG().catch((err) => {
|
||||
console.error('[HarnessNG] Init failed:', err);
|
||||
// Signal failure so tests don't hang
|
||||
(window as any).__testData = { ready: false, error: err.message };
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Harness component — uses real useShape, exposes window.__testData
|
||||
// ============================================================================
|
||||
|
||||
function DataHarnessNG() {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Wait for NG session before rendering useShape hooks
|
||||
if (!sessionReady) {
|
||||
return <WaitForSession onReady={() => setReady(true)} onError={setError} />;
|
||||
}
|
||||
|
||||
return <ShapeConsumer />;
|
||||
}
|
||||
|
||||
function WaitForSession({ onReady, onError }: { onReady: () => void; onError: (e: string) => void }) {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (sessionReady) {
|
||||
clearInterval(interval);
|
||||
onReady();
|
||||
}
|
||||
}, 100);
|
||||
const timeout = setTimeout(() => {
|
||||
clearInterval(interval);
|
||||
if (!sessionReady) {
|
||||
onError('NG session timeout after 30s');
|
||||
}
|
||||
}, 30000);
|
||||
return () => { clearInterval(interval); clearTimeout(timeout); };
|
||||
}, []);
|
||||
|
||||
return <div id="harness-status">WAITING_FOR_SESSION</div>;
|
||||
}
|
||||
|
||||
function ShapeConsumer() {
|
||||
// Use "did:ng:i" scope = all data in private store (same as the app)
|
||||
const events = useShape(FpEventShapeType, 'did:ng:i') as DeepSignalSet<FpEvent>;
|
||||
const users = useShape(FpUserProfileShapeType, 'did:ng:i') as DeepSignalSet<FpUserProfile>;
|
||||
const participations = useShape(FpParticipationShapeType, 'did:ng:i') as DeepSignalSet<FpParticipation>;
|
||||
|
||||
const [bridgeReady, setBridgeReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait a tick for useShape to populate
|
||||
const timer = setTimeout(() => {
|
||||
const graph = `did:ng:${ngSession.private_store_id}`;
|
||||
|
||||
// Seed events if the graph is empty
|
||||
if (events.size === 0) {
|
||||
console.log('[HarnessNG] Graph empty, seeding test events...');
|
||||
for (const e of SEED_EVENTS) {
|
||||
events.add({ ...e, '@graph': graph, '@id': '' } as FpEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed a test user if none exists
|
||||
if (users.size === 0) {
|
||||
console.log('[HarnessNG] No user profile, seeding test user...');
|
||||
users.add({
|
||||
'@graph': graph,
|
||||
'@id': '',
|
||||
'@type': 'http://festipod.org/UserProfile',
|
||||
name: 'Test User',
|
||||
initials: 'TU',
|
||||
username: '@testuser',
|
||||
} as FpUserProfile);
|
||||
}
|
||||
|
||||
// Get current user ID (wait briefly for NG to assign @id)
|
||||
let currentUserId = '';
|
||||
const existingUsers = [...users];
|
||||
if (existingUsers.length > 0) {
|
||||
currentUserId = existingUsers[0]['@id'];
|
||||
}
|
||||
|
||||
// Expose the test bridge
|
||||
(window as any).__testData = {
|
||||
ready: true,
|
||||
events,
|
||||
users,
|
||||
participations,
|
||||
currentUserId,
|
||||
session: ngSession,
|
||||
|
||||
getEvent(id: string) {
|
||||
return [...events].find(e => e['@id'] === id);
|
||||
},
|
||||
getEventByTitle(title: string) {
|
||||
return [...events].find(e => e.title === title);
|
||||
},
|
||||
isParticipating(eventId: string, userId: string) {
|
||||
return [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
},
|
||||
getEventParticipants(eventId: string) {
|
||||
return [...participations].filter(p => p.event === eventId);
|
||||
},
|
||||
joinEvent(eventId: string, userId: string) {
|
||||
const already = [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
if (already) return;
|
||||
participations.add({
|
||||
'@graph': `did:ng:${ngSession.private_store_id}`,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': '', // auto-assigned by NG
|
||||
event: eventId,
|
||||
user: userId,
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = ev.participantCount + 1;
|
||||
},
|
||||
leaveEvent(eventId: string, userId: string) {
|
||||
const part = [...participations].find(p => p.event === eventId && p.user === userId);
|
||||
if (!part) return;
|
||||
participations.delete(part);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
|
||||
},
|
||||
updateEvent(eventId: string, updates: Record<string, any>) {
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (!ev) return;
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key !== '@id' && key !== '@graph' && key !== '@type') {
|
||||
(ev as any)[key] = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[HarnessNG] Ready — events:', events.size, 'users:', users.size, 'participations:', participations.size);
|
||||
setBridgeReady(true);
|
||||
}, 500); // Small delay for useShape to populate
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [events, users, participations]);
|
||||
|
||||
return <div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bootstrap
|
||||
// ============================================================================
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(<DataHarnessNG />);
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Data-layer test harness (mock mode).
|
||||
*
|
||||
* Uses DeepSignalSet directly — the same reactive data structure that
|
||||
* useShape returns — seeded with test data. No NextGraph broker required.
|
||||
*
|
||||
* Exposes window.__testData for Playwright-driven Cucumber steps.
|
||||
*
|
||||
* When a real broker is available, swap this for harness-ng.tsx.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { deepSignal } from '@ng-org/alien-deepsignals';
|
||||
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
|
||||
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
|
||||
|
||||
// ============================================================================
|
||||
// Seed data — same events/users as the app's seedData.ts
|
||||
// ============================================================================
|
||||
|
||||
const GRAPH = 'did:ng:test';
|
||||
|
||||
function seedEvents(): DeepSignalSet<FpEvent> {
|
||||
const set = deepSignal(new Set<FpEvent>()) as DeepSignalSet<FpEvent>;
|
||||
const events: Array<Omit<FpEvent, '@graph' | '@id'>> = [
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Résidence Reconnexion', date: 'Lun. 16 - Ven. 20 fév.', location: 'Écocentre de Villarceaux', participantCount: 24 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Marché des créateurs', date: 'Sam. 22 fév. · 10h', location: 'Place Bellecour, Lyon', participantCount: 12 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Cercle de parole', date: 'Dim. 23 fév. · 14h', location: 'Maison des associations', participantCount: 45 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Formation CNV', date: 'Sam. 1 mars · 9h30', location: 'Centre Iris, Paris', participantCount: 16 },
|
||||
{ '@type': 'http://festipod.org/Event', title: 'Festival Printemps', date: 'Ven. 14 - Dim. 16 mars', location: 'Domaine de Longchamp', participantCount: 30 },
|
||||
];
|
||||
for (const e of events) {
|
||||
set.add({ ...e, '@graph': GRAPH, '@id': `event:${e.title}` } as FpEvent);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function seedUsers(): DeepSignalSet<FpUserProfile> {
|
||||
const set = deepSignal(new Set<FpUserProfile>()) as DeepSignalSet<FpUserProfile>;
|
||||
const users: Array<Omit<FpUserProfile, '@graph' | '@id'>> = [
|
||||
{ '@type': 'http://festipod.org/UserProfile', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' },
|
||||
{ '@type': 'http://festipod.org/UserProfile', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' },
|
||||
{ '@type': 'http://festipod.org/UserProfile', name: 'Thomas Martin', initials: 'TM', username: '@thomasmartin' },
|
||||
];
|
||||
for (const u of users) {
|
||||
set.add({ ...u, '@graph': GRAPH, '@id': `user:${u.username}` } as FpUserProfile);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
function seedParticipations(
|
||||
events: DeepSignalSet<FpEvent>,
|
||||
users: DeepSignalSet<FpUserProfile>,
|
||||
): DeepSignalSet<FpParticipation> {
|
||||
const set = deepSignal(new Set<FpParticipation>()) as DeepSignalSet<FpParticipation>;
|
||||
const marie = [...users].find(u => u.username === '@mariedupont')!;
|
||||
const jean = [...users].find(u => u.username === '@jeandurand')!;
|
||||
const thomas = [...users].find(u => u.username === '@thomasmartin')!;
|
||||
const ev1 = [...events].find(e => e.title === 'Résidence Reconnexion')!;
|
||||
const ev2 = [...events].find(e => e.title === 'Marché des créateurs')!;
|
||||
const ev3 = [...events].find(e => e.title === 'Cercle de parole')!;
|
||||
|
||||
// Marie participates in events 1, 2, 3
|
||||
for (const ev of [ev1, ev2, ev3]) {
|
||||
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${marie['@id']}:${ev['@id']}`, event: ev['@id'], user: marie['@id'], isConfirmed: true } as FpParticipation);
|
||||
}
|
||||
// Jean participates in event 1
|
||||
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${jean['@id']}:${ev1['@id']}`, event: ev1['@id'], user: jean['@id'], isConfirmed: true } as FpParticipation);
|
||||
// Thomas participates in event 1
|
||||
set.add({ '@graph': GRAPH, '@type': 'http://festipod.org/Participation', '@id': `part:${thomas['@id']}:${ev1['@id']}`, event: ev1['@id'], user: thomas['@id'], isConfirmed: true } as FpParticipation);
|
||||
|
||||
return set;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Harness component — exposes window.__testData
|
||||
// ============================================================================
|
||||
|
||||
function DataHarness() {
|
||||
const eventsRef = useRef(seedEvents());
|
||||
const usersRef = useRef(seedUsers());
|
||||
const participationsRef = useRef(seedParticipations(eventsRef.current, usersRef.current));
|
||||
|
||||
const events = eventsRef.current;
|
||||
const users = usersRef.current;
|
||||
const participations = participationsRef.current;
|
||||
|
||||
// Current user = first user (Marie)
|
||||
const currentUser = [...users][0];
|
||||
|
||||
useEffect(() => {
|
||||
(window as any).__testData = {
|
||||
ready: true,
|
||||
events,
|
||||
users,
|
||||
participations,
|
||||
currentUserId: currentUser?.['@id'] || '',
|
||||
|
||||
getEvent(id: string) {
|
||||
return [...events].find(e => e['@id'] === id);
|
||||
},
|
||||
getEventByTitle(title: string) {
|
||||
return [...events].find(e => e.title === title);
|
||||
},
|
||||
isParticipating(eventId: string, userId: string) {
|
||||
return [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
},
|
||||
getEventParticipants(eventId: string) {
|
||||
return [...participations].filter(p => p.event === eventId);
|
||||
},
|
||||
joinEvent(eventId: string, userId: string) {
|
||||
const already = [...participations].some(p => p.event === eventId && p.user === userId);
|
||||
if (already) return;
|
||||
participations.add({
|
||||
'@graph': GRAPH,
|
||||
'@type': 'http://festipod.org/Participation',
|
||||
'@id': `part:${userId}:${eventId}:${Date.now()}`,
|
||||
event: eventId,
|
||||
user: userId,
|
||||
isConfirmed: true,
|
||||
} as FpParticipation);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = ev.participantCount + 1;
|
||||
},
|
||||
leaveEvent(eventId: string, userId: string) {
|
||||
const part = [...participations].find(p => p.event === eventId && p.user === userId);
|
||||
if (!part) return;
|
||||
participations.delete(part);
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (ev) ev.participantCount = Math.max(0, ev.participantCount - 1);
|
||||
},
|
||||
updateEvent(eventId: string, updates: Record<string, any>) {
|
||||
const ev = [...events].find(e => e['@id'] === eventId);
|
||||
if (!ev) return;
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key !== '@id' && key !== '@graph' && key !== '@type') {
|
||||
(ev as any)[key] = value;
|
||||
}
|
||||
}
|
||||
},
|
||||
clearAll() {
|
||||
for (const p of [...participations]) participations.delete(p);
|
||||
for (const e of [...events]) events.delete(e);
|
||||
for (const u of [...users]) users.delete(u);
|
||||
},
|
||||
};
|
||||
console.log('[TestHarness] Ready — events:', events.size, 'users:', users.size, 'participations:', participations.size);
|
||||
}, []);
|
||||
|
||||
return <div id="harness-status">READY</div>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Bootstrap
|
||||
// ============================================================================
|
||||
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
root.render(<DataHarness />);
|
||||
Reference in New Issue
Block a user