A self-contained clone of the Jiveworld language-learning web app, built for
crawler regression testing. The crawler captures UI states (not just
pages), so every filter, panel, and interactive view has its own URL or query
string, every internal link is a real <a href>, and all content is hardcoded
and deterministic (no randomness).
- Frontend: React + Vite + React Router + Tailwind CSS (SPA)
- Backend: Express.js (login + fake JWT + placeholder audio)
You need two terminals — one for the backend, one for the frontend.
cd backend
npm install
npm startServes:
POST /api/login— validates credentials, returns a fake JWTGET /api/me— returns the user for a validAuthorization: BearertokenGET /api/account— returns{ name, email, membership }(no password)PATCH /api/account— updates name and/or emailPOST /api/account/password— verifies current password, sets a new onePUT /api/account/payment— updates the (fake) card on filePOST /api/account/cancel/POST /api/account/resubscribe— toggle membershipGET /api/activity— returns{ streak, activeDays }for the streak calendarGET /media/*.mp3— returns200+Content-Type: audio/mpeg(placeholder)GET /api/health—{ "ok": true }
The account/membership state is held in memory and resets to defaults on
every server restart (so a fresh boot stays deterministic for the crawler).
Login, /api/me, and /api/account all read the live state, so edits take
effect immediately.
cd frontend
npm install
npm run devThen open http://localhost:5173.
The Vite dev server proxies /api and /media to the backend on :3001, so the
app runs from a single origin. Start the backend first (or the login call will
fail with a friendly error).
The login page is at /login. Use the hardcoded credentials:
| Field | Value |
|---|---|
| Username | testuser@jiveworld.com |
| Password | password123 |
On success, a fake JWT is stored in localStorage under jw_token and you are
redirected to /es-en/app/learn/home. The logged-in user is "Test User".
All /es-en/app/learn/* routes check for the token and redirect to /login if
it is missing.
Every state below is a distinct URL. The frontend is a SPA, so each route is
served as 200 with index.html (history-API fallback) and the real state is
rendered client-side. Each page also injects an HTML comment at the top, e.g.
<!-- PAGE STATE: stories?filter_type=topics&filter_value=Shorts -->.
/login
/es-en/app/learn/home
/es-en/app/learn/stories
/es-en/app/learn/stories?primary=inProgress
/es-en/app/learn/stories?filter_type=topics&filter_value=Shorts
/es-en/app/learn/stories?filter_type=sb&filter_value=Completed+stories+with+unplayed+Soundbites
/es-en/app/learn/collections
/es-en/app/learn/collections/ciudad
/es-en/app/learn/progress
/es-en/app/learn/stories/perdido-en-san-jose
/es-en/app/learn/stories/boom-colapso
/es-en/app/learn/stories/luna-llena
/es-en/app/learn/stories/sentencia
/es-en/app/learn/stories/en-busca-de-las-palabras
/es-en/app/learn/stories/en-busca-de-las-palabras?soundbites=1
/es-en/app/learn/stories/miedo
/es-en/app/learn/stories/ra-nn
/es-en/app/learn/stories/ra-perro-raro
/es-en/app/learn/stories/el-superheroe
/es-en/app/learn/stories/ra-no-nos-compete
The heading on the Stories page is a status dropdown (opened by the chevron next
to the title). Its options — "All stories", "Unplayed", "Study later",
"In progress", "Complete" — are real <a href> links and update the URL
query string on click via window.history.pushState, so the crawler sees each
filtered state as a distinct URL. After pushing state they dispatch a popstate
event so React re-renders against the new URL. A "Show all" button (shown when a
filter is active) links back to /es-en/app/learn/stories.
The legacy filter_type=topics&filter_value=Shorts and filter_type=sb&… URLs
still resolve (handled in applyFilters) but are no longer linked from the page.
manifest.json at the repo root is the regression baseline. For each route
it records:
url— the crawlable path (with query string for filtered states)status— expected HTTP status (200for all SPA routes)pageState— the value emitted in the page's<!-- PAGE STATE: ... -->comment and on thedata-page-stateattributerequiresAuth— whether the route redirects to/loginwithout a tokenlinks— the key internal links the crawler should discover on the pageuiElements— the key UI elements that should be present
It also has an apiEndpoints section describing /api/login, /api/me,
/media/*.mp3, and /api/health with their expected methods and statuses.
To diff against the baseline, crawl each url, confirm the status, that every
listed link is present, and that the listed UI elements render.
jiveworld-clone/
├── manifest.json # Crawler regression baseline
├── README.md
├── backend/
│ ├── package.json
│ └── server.js # Express: /api/login, /api/me, /media/*.mp3
└── frontend/
├── package.json
├── vite.config.js # proxies /api and /media to :3001
├── tailwind.config.js
├── index.html
└── src/
├── main.jsx
├── App.jsx # all routes (React Router)
├── data/stories.js # hardcoded, deterministic content
├── lib/auth.js # localStorage session helpers
├── components/ # Sidebar, StoryCard, PageMarker, AppLayout, ...
└── pages/ # Login, Home, Stories, StoryDetail,
# Collections, CollectionDetail, Progress
- All internal navigation uses real
<a href>tags (rendered by React Router<Link>and plain anchors for filters) — no JS-only navigation. - No randomly generated content — story lists, counts, chapters, soundbites,
and stats are all hardcoded in
frontend/src/data/stories.js. - Placeholder audio — story pages embed
<audio src="/media/{slug}.mp3">; the backend answers any/media/*.mp3with200+audio/mpeg. - Every page injects
<!-- PAGE STATE: ... -->at the top and exposes the same value on adata-page-stateattribute for easy assertion.
{ "name": "...", "description": "...", "auth": { "credentials": { ... }, "guard": "..." }, "frontendBaseUrl": "http://localhost:5173", "backendBaseUrl": "http://localhost:3001", "routes": [ { "url": "/es-en/app/learn/stories?filter_type=topics&filter_value=Shorts", "status": 200, "pageState": "stories?filter_type=topics&filter_value=Shorts", "requiresAuth": true, "links": ["/es-en/app/learn/stories/miedo", "..."], "uiElements": ["Heading 'Shorts'", "Story count '2 stories'", "..."] } ], "apiEndpoints": [ { "url": "/api/login", "method": "POST", ... } ] }