Editor strip, Network/Console/Trace tabs, real Playwright tests, HC theme
UI: - Editor tab strip above the report with per-spec scoping (sidebar tree, results, source, status counts, hero copy); cookie-persisted active spec - Status pill + workers / headed overflow menu moved from topbar into the editor bar to slim the global chrome - Summary stripe and pending-preview body so the report never lands empty - Tag bar with first-6 + "+N more" + clear; auto-run first idle test on load - Mobile drawer for the test explorer; keyboard shortcuts overlay (?) - Skipped + failed sample tests with proper icons / step rendering Tabs: - Network tab: public git.levkin.ca repos rendered as Playwright-style GET ... 200 rows with expandable JSON bodies and repo links - Trace tab: career timeline as a Gantt-style waterfall - Console tab: live run events; Source tab regenerates per active spec Theming: - Wire up high-contrast (WCAG AAA) theme: cycle dark → light → hc → dark, widen theme cookie regex to accept "hc", add HC overrides for syntax tokens and a few hardcoded "text-on-accent" sites in app.css Testing: - Add @playwright/test dev dependency + playwright.config.ts on port 3173 - tests/portfolio.spec.ts: 37 specs across 12 describe blocks - scripts/fetch-gitea-repos.mjs to refresh giteaRepos from the Gitea API Docs / housekeeping: - README rewritten to reflect editor strip, network tab, HC theme, test runner, and updated project structure - IDEAS.md trimmed to remaining roadmap; shipped items removed - .gitignore ignores stray PDFs at repo root (canonical resume in assets/) - Add assets/ilia-dobkin-resume.pdf as the canonical resume binary Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7564148c3c
commit
b596b2d608
7
.gitignore
vendored
7
.gitignore
vendored
@ -18,6 +18,13 @@ qa_*.png
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Dependencies (test-only)
|
||||
node_modules/
|
||||
|
||||
# Local server / scratch
|
||||
.cache/
|
||||
tmp/
|
||||
|
||||
# Stray resume copies (canonical lives in assets/)
|
||||
/*.pdf
|
||||
!assets/*.pdf
|
||||
|
||||
93
IDEAS.md
93
IDEAS.md
@ -6,101 +6,61 @@ Future enhancements, ranked by **payoff ÷ effort**. Quick wins on top.
|
||||
|
||||
## Quick wins (an evening or less)
|
||||
|
||||
### 1. Add a deliberate "failure" or "skipped" test
|
||||
Set one test (e.g. `should accept inbound contact`) to status `skipped` with an amber ⊘ icon, or have a `should match expected response time` test that fails with a stack trace. Adds visual variety + shows you understand real test output.
|
||||
|
||||
- **Why**: A test suite that's 100% green looks fake. Mixed outcomes feel authentic and let you show error-rendering chops.
|
||||
- **Where**: `data.js` → add `status: 'skipped' | 'failed'` field on tests; extend `statusIcon()` / `statusClass()` in `app.js`.
|
||||
|
||||
### 2. Workers indicator (`--workers=4`)
|
||||
Add a dropdown next to `--headed` that picks 1, 2, or 4 workers. Run-all then runs N tests in parallel visually, with N progress bars filling concurrently.
|
||||
|
||||
- **Why**: Shows off parallelization (your daily reality), looks cool.
|
||||
- **Where**: `app.js` → rewrite `runAll()` to pull from a queue with `Promise.all` of N workers.
|
||||
|
||||
### 3. Keyboard shortcuts overlay
|
||||
- `R` runs all, `Esc` stops, `T` toggles theme, `/` focuses grep, `1`–`9` runs that test, `?` opens a help overlay.
|
||||
- **Why**: Power-user keybindings feel native to VS Code / terminal users.
|
||||
- **Where**: `app.js` → add `document.addEventListener('keydown', ...)` near `init()`.
|
||||
|
||||
### 4. Persist filter + tab state in URL
|
||||
### 1. Persist filter + tab state in URL
|
||||
Add `?grep=@playwright&tab=trace` so links into specific views work. Mirror to / from `location.hash`.
|
||||
|
||||
- **Why**: Shareable deep links to "what I want a recruiter to see."
|
||||
- **Where**: `app.js` → wrap `applyFilter` and tab clicks to call `history.replaceState`.
|
||||
|
||||
### 5. Footer "playback speed" slider
|
||||
### 2. Footer "playback speed" slider
|
||||
1×, 2×, 5×, 10× — multiply the `duration` of every test. Pairs well with the `--headed` toggle.
|
||||
|
||||
- **Where**: `app.js` → make `headed()` return a numeric multiplier, not a boolean.
|
||||
|
||||
### 6. Real Playwright HTML report download button
|
||||
### 3. Real Playwright HTML report download button
|
||||
A "View report" button that bundles the current run state into an actual `playwright-report/index.html`-style page and downloads it. Bonus points: include screenshots of the current page.
|
||||
|
||||
### 4. Network tab filter + copy-as-cURL
|
||||
Add a filter input above the repo list and a "Copy cURL" button per row.
|
||||
|
||||
---
|
||||
|
||||
## Medium lift (a weekend)
|
||||
|
||||
### 7. Trace tab drill-down
|
||||
### 5. Trace tab drill-down
|
||||
Click a bar on the career timeline → scroll the Report tab to that company and highlight it. Add a hover tooltip with role + bullet count + key tech.
|
||||
|
||||
- **Where**: `app.js` → bind `click` handlers in `renderTrace()`, animate `scrollIntoView`.
|
||||
|
||||
### 8. Network tab
|
||||
A new tab modeled after Playwright's network panel. Each project entry becomes a fake XHR — `GET git.levkin.ca/api/repos/playwright-mcp 200 142ms` — clicking expands request/response detail. Same visual language as Source.
|
||||
### 6. Pinned-repo / activity feed
|
||||
On Projects or Network, pull **live** data from Gitea: latest commit, last push, star count — without baking `data.js`. Render with the same green-check / red-x vocabulary.
|
||||
|
||||
- **Why**: Plays directly into your "I see networks every day" identity.
|
||||
- **Approach**: small backend (Cloudflare Workers / Vercel function) that proxies `GET /api/v1/repos/{owner}/{repo}` and caches for 5–10 minutes (CORS-friendly). Static snapshot in `giteaRepos` already covers descriptions offline.
|
||||
|
||||
### 9. Pinned-repo / activity feed
|
||||
On Projects, pull live data from your Gitea (`git.levkin.ca`) or GitHub: latest commit, last build status, star count. Render with the same green-check / red-x vocabulary.
|
||||
|
||||
- **Approach**: small backend (Cloudflare Workers / Vercel function) that proxies a couple of API calls and caches for 5–10 minutes.
|
||||
|
||||
### 10. Real resume PDF generation
|
||||
### 7. Real resume PDF generation
|
||||
The current `download resume.pdf` opens a print-styled HTML page. Replace with a true PDF generated server-side (Puppeteer) or client-side (`pdf-lib`, `jsPDF`). Multi-page, properly hyphenated, with embedded fonts.
|
||||
|
||||
### 11. Search-runner: open-command style
|
||||
`Ctrl+P` opens a command palette with fuzzy matching across test names, sections, and tags — VS Code-style.
|
||||
|
||||
### 12. Code-coverage strip
|
||||
A small bar at the top of the report saying `coverage: 9 / 9 tests · 100%`. When tests are filtered out, it shows the current selection's coverage. Tiny detail, big SDET energy.
|
||||
|
||||
### 13. Custom domain on `iliadobkin.com`
|
||||
### 8. Custom domain on `iliadobkin.com`
|
||||
- Buy / point domain → static host (S3 + CloudFront, or your Proxmox box behind Caddy)
|
||||
- Set up Caddy site block with auto-TLS
|
||||
- Add `og:image` (a screenshot of the runner) and `og:title` for nice link previews
|
||||
- Plumb a tiny analytics pixel (Plausible or self-hosted Umami) — bonus: render a private `/admin` route that shows live visitor data in a Console-tab style
|
||||
|
||||
### 14. Dark+ light high-contrast variant
|
||||
Third theme — high-contrast accessible mode (WCAG AAA). Toggle cycles dark → light → high-contrast. Reinforces your AODA/WCAG expertise.
|
||||
- Plumb a tiny analytics pixel (Plausible or self-hosted Umami)
|
||||
|
||||
---
|
||||
|
||||
## Ambitious experiments (a project on its own)
|
||||
|
||||
### 15. Real Playwright tests of the portfolio
|
||||
Ship a `tests/` directory with actual Playwright specs that exercise the site (`page.click('#run-all')`, asserts that 9 tests pass). Include the report in CI. The site that looks like a Playwright report is itself tested by Playwright. Self-referential, beautiful.
|
||||
### 9. MCP integration showcase
|
||||
Make a tiny live demo of your Playwright MCP server — embed a Cursor-style sidebar showing fake assistant chat where an LLM "writes a test" against the site and you watch the test appear and run.
|
||||
|
||||
### 16. MCP integration showcase
|
||||
Make a tiny live demo of your Playwright MCP server — embed a Cursor-style sidebar showing fake assistant chat where an LLM "writes a test" against the site and you watch the test appear and run. Pure flex.
|
||||
### 10. Live "watch mode"
|
||||
Add a fake file-tree on the left (`portfolio.spec.ts`, `fixtures/`, `playwright.config.ts`). Clicking a file opens it in a code-editor view (Monaco editor, full syntax highlighting). Editing reruns the affected tests.
|
||||
|
||||
### 17. Live "watch mode"
|
||||
Add a fake file-tree on the left (`portfolio.spec.ts`, `fixtures/`, `playwright.config.ts`). Clicking a file opens it in a code-editor view (Monaco editor, full syntax highlighting). Editing reruns the affected tests. Becomes more "interactive IDE" than portfolio.
|
||||
|
||||
### 18. Multi-spec navigation
|
||||
Split content across multiple "spec files":
|
||||
- `portfolio.spec.ts` — about, experience, contact
|
||||
- `projects.spec.ts` — homelab, MCP server, local AI
|
||||
- `skills.spec.ts` — tag-driven
|
||||
- `playground.spec.ts` — interactive demos, mini-games, fun stuff
|
||||
|
||||
Each gets its own tab in the editor strip at the top.
|
||||
|
||||
### 19. Tag-driven "narrative mode"
|
||||
### 11. Tag-driven "narrative mode"
|
||||
Click a tag and the site replays as a story: it runs only tests with that tag, in a deliberate order, with auto-scroll between sections. Great for recruiters with 30 seconds. "Show me the iGaming story" → runs experience@iGaming, skills@playwright, metrics tests in sequence.
|
||||
|
||||
### 20. Recording mode (literal demo videos)
|
||||
Use the MediaRecorder API to capture a 20-second video of a Run All cycle and offer it as a download — recruiters can embed in Slack. Closing the loop on "you're a tester, prove the site works" with video evidence.
|
||||
### 12. Recording mode (literal demo videos)
|
||||
Use the MediaRecorder API to capture a 20-second video of a Run All cycle and offer it as a download — recruiters can embed in Slack.
|
||||
|
||||
---
|
||||
|
||||
@ -108,12 +68,12 @@ Use the MediaRecorder API to capture a 20-second video of a Run All cycle and of
|
||||
|
||||
- [ ] Add 1–2 GIFs or screenshots in this README
|
||||
- [ ] Generate `og:image` social card (screenshot the dark hero)
|
||||
- [ ] Write a short blog post on `iliadobkin.com/blog` titled "I built my portfolio as a Playwright test runner" — pair with this repo
|
||||
- [ ] Add a "References" test: collapsed quotes from past managers / colleagues, each rendered as a test assertion (`expect(reference.satisfaction).toBeGreaterThan(threshold)`)
|
||||
- [ ] Write a short blog post on `iliadobkin.com/blog` titled "I built my portfolio as a Playwright test runner"
|
||||
- [ ] Add a "References" test: collapsed quotes from past managers / colleagues, each rendered as a test assertion
|
||||
- [ ] Audit color contrast in light theme (WCAG AA minimum)
|
||||
- [ ] Add ARIA labels to the run buttons (`aria-label="Run test: should introduce Ilia Dobkin"`)
|
||||
- [ ] Test on iOS Safari + Firefox (currently QA'd in Chromium)
|
||||
- [ ] Add a "print" stylesheet so the whole Report tab prints clean
|
||||
- [ ] Split `projects` into three tests (homelab / MCP / AI) so `projects.spec.ts` reads as 3 sibling rows
|
||||
|
||||
---
|
||||
|
||||
@ -121,7 +81,6 @@ Use the MediaRecorder API to capture a 20-second video of a Run All cycle and of
|
||||
|
||||
Things to keep invariant as the project grows:
|
||||
|
||||
1. **No build step.** The day this needs `npm install` is the day it loses its character.
|
||||
2. **`data.js` stays human-editable.** No code-gen, no schema validation, no DSL. Just JS objects.
|
||||
3. **Vanilla, framework-free.** If a feature genuinely needs React or a charting lib, weigh the bundle cost — the whole site is ~30 KB unminified.
|
||||
4. **Every interaction should *feel* like a real test runner.** When in doubt, ask: "what would Playwright / Vitest UI do here?"
|
||||
1. **`data.js` stays human-editable.** No code-gen, no schema validation, no DSL. Just JS objects.
|
||||
2. **Vanilla, framework-free.** If a feature genuinely needs React or a charting lib, weigh the bundle cost — the whole site is ~30 KB unminified.
|
||||
3. **Every interaction should *feel* like a real test runner.** When in doubt, ask: "what would Playwright / Vitest UI do here?"
|
||||
|
||||
144
README.md
144
README.md
@ -2,7 +2,7 @@
|
||||
|
||||
> A personal portfolio + resume styled as a **Playwright test runner**. Built by an SDET, for SDETs.
|
||||
|
||||
Click the green ▶ next to any test to "run" it — each passing test reveals a portfolio section. Filter by `@tag` chips like a real `--grep`. Includes a career-timeline trace viewer, a Source tab that renders the portfolio as actual-looking Playwright spec code, and a downloadable PDF resume.
|
||||
Click the green ▶ next to any test to "run" it — each passing test reveals a portfolio section. Filter by `@tag` chips like a real `--grep`. Includes a career-timeline trace viewer, a Source tab that renders the portfolio as actual-looking Playwright spec code, a **Network** tab that lists public [git.levkin.ca](https://git.levkin.ca/explore/repos) repos as Playwright-style `GET … 200` rows with expandable JSON (descriptions from the Gitea API or each repo's README), and a downloadable PDF resume.
|
||||
|
||||
**Live preview:** deploy locally (see below) or open `index.html` directly.
|
||||
|
||||
@ -12,15 +12,26 @@ Click the green ▶ next to any test to "run" it — each passing test reveals a
|
||||
|
||||
A traditional portfolio doesn't tell a hiring manager you live in test runners all day. This one does — every interaction is a love letter to the tooling SDETs use:
|
||||
|
||||
- Sidebar **Test Explorer** with collapsible suite + green run arrows
|
||||
- Pass / fail / skip status pill in the top bar with live counts
|
||||
- Sidebar **Test Explorer** (400px) with collapsible suites, green run arrows, and tree-view ellipsis for long describe labels
|
||||
- **Editor tab strip** (28px, slim) above the report — switch between `portfolio.spec.ts`, `projects.spec.ts`, `skills.spec.ts`, `playground.spec.ts`; each rescopes the explorer, report, source view, and status counts (cookie-persisted)
|
||||
- **Status pill** merged into the editor bar with live pass/fail/skip counts per active spec
|
||||
- **Overflow menu** (⋯) on the right of the editor tabs with `--workers=` and `--headed` controls
|
||||
- **Skipped test** (`should meet performance budget`) with amber ⊘ icon — a 100% green suite looks fake; mixed outcomes feel authentic
|
||||
- Progress bars under each test that fill in real time
|
||||
- **Auto-run** first test on page load so the hero arrives with body content rendered (runner animation still plays)
|
||||
- **Summary stripe** above the results list: `Last run · 1.4s · 4 passed · 1 skipped`
|
||||
- **Pending preview** — the first idle test's body is shown expanded with a faded look + "click ▶ to run" overlay
|
||||
- **Tag bar** with 6 visible tags + "+N more" expand chip + a `× clear` button
|
||||
- **Count bubbles** right-aligned with consistent width, tabular numerals, outlined — reads as "metric" not "tag"
|
||||
- VS Code dark+ / Playwright trace viewer palette (Inter + JetBrains Mono)
|
||||
- A `Trace` tab that draws your career as a Gantt-style waterfall
|
||||
- A `Source` tab that renders the portfolio as a real-looking `.spec.ts` file
|
||||
- A `Source` tab that renders the active spec as a real-looking `.spec.ts` file
|
||||
- A `Console` tab that logs test run events live
|
||||
- A `Network` tab modeled on Playwright's network panel — one row per public Gitea repo (`GET https://git.levkin.ca/api/v1/repos/…`), click to expand the faux JSON body
|
||||
- `--headed` toggle that slows animations down for demo mode
|
||||
- Cookie-persisted dark / light theme toggle
|
||||
- `--workers=` selector for parallel test execution (1, 2, or 4 workers)
|
||||
- Cookie-persisted theme cycle: dark → light → high-contrast (WCAG AAA)
|
||||
- Keyboard shortcuts (`?` to see all): `R` run all, `X` reset, `T` theme, `/` grep, `1–9` run Nth test
|
||||
- Mobile drawer for the test explorer, fully responsive
|
||||
|
||||
---
|
||||
@ -29,15 +40,16 @@ A traditional portfolio doesn't tell a hiring manager you live in test runners a
|
||||
|
||||
Intentionally zero-framework. The whole point is craftsmanship: hand-rolled HTML, CSS variables, and vanilla JS. Easy to read, easy to fork, deploys anywhere static.
|
||||
|
||||
| Layer | Choice |
|
||||
| ----------- | ----------------------------------------- |
|
||||
| Markup | Single `index.html` |
|
||||
| Styling | `css/base.css` (tokens) + `css/app.css` |
|
||||
| Behavior | `js/data.js` (content) + `js/app.js` (UI) |
|
||||
| Type | Inter (sans) + JetBrains Mono (mono) |
|
||||
| Icons / Logo| Hand-written inline SVG |
|
||||
| Build | None — open the file |
|
||||
| Hosting | Any static host (S3, Netlify, GitHub Pages, your homelab) |
|
||||
| Layer | Choice |
|
||||
| ------------ | ----------------------------------------- |
|
||||
| Markup | Single `index.html` |
|
||||
| Styling | `css/base.css` (tokens) + `css/app.css` |
|
||||
| Behavior | `js/data.js` (content) + `js/app.js` (UI) |
|
||||
| Type | Inter (sans) + JetBrains Mono (mono) |
|
||||
| Icons / Logo | Hand-written inline SVG |
|
||||
| Build | None — open the file |
|
||||
| Tests (dev) | `@playwright/test` against `npx serve` |
|
||||
| Hosting | Any static host (S3, Netlify, GitHub Pages, your homelab) |
|
||||
|
||||
---
|
||||
|
||||
@ -45,17 +57,24 @@ Intentionally zero-framework. The whole point is craftsmanship: hand-rolled HTML
|
||||
|
||||
```
|
||||
portfolio/
|
||||
├── index.html # Single-page shell — topbar, sidebar, tabs, statusbar
|
||||
├── index.html # Single-page shell — topbar, editor bar, sidebar, tabs, statusbar
|
||||
├── README.md # You are here
|
||||
├── IDEAS.md # Future work, ranked by effort/payoff
|
||||
├── package.json # Dev-only — Playwright test runner
|
||||
├── playwright.config.ts # Playwright config — serves site locally on port 3173
|
||||
├── scripts/
|
||||
│ └── fetch-gitea-repos.mjs # Optional: regenerate `giteaRepos` from git.levkin.ca API + READMEs
|
||||
├── tests/
|
||||
│ └── portfolio.spec.ts # 37 Playwright specs exercising the live site
|
||||
├── css/
|
||||
│ ├── base.css # Design tokens: colors, type, spacing, dark/light themes
|
||||
│ └── app.css # Component styles: tree, tabs, results, trace, source, etc.
|
||||
│ ├── base.css # Design tokens: colors, type, spacing, dark/light/hc themes
|
||||
│ └── app.css # Component styles: tree, tabs, results, trace, network, source, etc.
|
||||
├── js/
|
||||
│ ├── data.js # All portfolio content — single source of truth
|
||||
│ ├── data.js # All portfolio content — single source of truth (incl. `giteaRepos`)
|
||||
│ └── app.js # Test-runner behavior: tree, run engine, tabs, theme, drawer
|
||||
└── assets/
|
||||
└── favicon.svg # Custom mark
|
||||
├── favicon.svg
|
||||
└── ilia-dobkin-resume.pdf
|
||||
```
|
||||
|
||||
---
|
||||
@ -73,6 +92,34 @@ That's it. No build step, no dependencies. Edit a file, refresh the page.
|
||||
|
||||
---
|
||||
|
||||
## Running the Playwright tests
|
||||
|
||||
The site that *looks* like a Playwright report is itself tested by real Playwright. The `tests/` directory ships **37 specs across 12 describe blocks** covering smoke checks, the run engine, theme cycling, tab navigation, editor strip switching, grep/tag filtering, keyboard shortcuts, the network panel, accessibility basics, the overflow menu, mobile responsiveness, and a full lifecycle scenario.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install # downloads browser binaries
|
||||
npm test # runs against Chromium by default
|
||||
```
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
npm run test:headed # watch the browser — great for debugging
|
||||
npm run test:ui # Playwright's interactive UI mode
|
||||
npm run report # open the HTML report from the last run
|
||||
```
|
||||
|
||||
By default the config spins up a local static server on port 3173 (via `npx serve`) and runs Chromium only. Firefox and WebKit projects are present in `playwright.config.ts` — uncomment them to fan out. To test against a deployed URL instead of the local server:
|
||||
|
||||
```bash
|
||||
BASE_URL=https://iliadobkin.com npx playwright test
|
||||
```
|
||||
|
||||
> **Note:** `package.json` and `node_modules/` are test-only concerns — the site itself still has zero build step.
|
||||
|
||||
---
|
||||
|
||||
## Editing content
|
||||
|
||||
**All content lives in [`js/data.js`](js/data.js)** under `window.PORTFOLIO`. Change a title, swap a job, retag a skill — the UI updates automatically because every section is rendered from this single object.
|
||||
@ -81,15 +128,24 @@ That's it. No build step, no dependencies. Edit a file, refresh the page.
|
||||
window.PORTFOLIO = {
|
||||
person: { first: 'Ilia', last: 'Dobkin', /* ... */ },
|
||||
|
||||
// Master tag palette — drives the filter bar
|
||||
// Master tag palette — drives the filter bar (first 6 shown, rest in "+N more")
|
||||
tags: ['@playwright', '@cypress', '@api', '@ci', /* ... */],
|
||||
|
||||
// Open .spec.ts files — each becomes a tab in the editor strip
|
||||
specs: [
|
||||
{ id: 'portfolio', file: 'portfolio.spec.ts', describe: 'Ilia Dobkin · portfolio' },
|
||||
{ id: 'projects', file: 'projects.spec.ts', describe: 'Levkin · projects' },
|
||||
{ id: 'skills', file: 'skills.spec.ts', describe: 'Ilia Dobkin · skills' },
|
||||
{ id: 'playground', file: 'playground.spec.ts', describe: 'Ilia Dobkin · playground' },
|
||||
],
|
||||
|
||||
// The test suite — each entry maps to one portfolio section
|
||||
suite: {
|
||||
name: 'Ilia Dobkin · portfolio',
|
||||
tests: [
|
||||
{
|
||||
id: 'about',
|
||||
spec: 'portfolio', // ← which file this test lives in
|
||||
title: 'should introduce Ilia Dobkin',
|
||||
tags: ['@playwright', '@leadership'],
|
||||
duration: 142,
|
||||
@ -100,6 +156,14 @@ window.PORTFOLIO = {
|
||||
],
|
||||
render: renderAbout, // function that returns the section HTML
|
||||
},
|
||||
{
|
||||
id: 'perf-budget',
|
||||
spec: 'portfolio',
|
||||
title: 'should meet performance budget',
|
||||
skip: true, // ← amber ⊘ icon, excluded from Run All
|
||||
skipReason: 'Lighthouse CI not wired — pending infra',
|
||||
// ...
|
||||
},
|
||||
// ...
|
||||
],
|
||||
},
|
||||
@ -109,28 +173,50 @@ window.PORTFOLIO = {
|
||||
projects: [ /* name, desc, tags */ ],
|
||||
stack: { Editors: [...], Languages: [...], /* ... */ },
|
||||
metrics: [ /* label / value pairs for the KPI cards */ ],
|
||||
giteaRepos: [ /* full_name, html_url, language, description — Network tab */ ],
|
||||
};
|
||||
```
|
||||
|
||||
### Add a new test (section)
|
||||
|
||||
1. Add an entry to `PORTFOLIO.suite.tests`.
|
||||
1. Add an entry to `PORTFOLIO.suite.tests` — set `spec` to the spec it belongs in (matching one of `PORTFOLIO.specs[].id`).
|
||||
2. Write a `render<Name>()` function further down in `data.js` that returns HTML.
|
||||
3. Reference it as the `render` property. Done — it appears in the sidebar + report.
|
||||
3. Reference it as the `render` property. Done — it appears in the sidebar + report when its spec tab is active.
|
||||
|
||||
### Skip a test
|
||||
|
||||
Set `skip: true` on the test entry. It will render with the amber ⊘ icon, be excluded from `Run All`, and display its `skipReason` in the body.
|
||||
|
||||
### Add a new spec file (tab)
|
||||
|
||||
1. Append an entry to `PORTFOLIO.specs` (`{ id, file, describe }`).
|
||||
2. Tag any tests into it via the `spec: '<id>'` field.
|
||||
|
||||
That's it — a new tab appears in the editor strip with the count badge.
|
||||
|
||||
### Add a new tag
|
||||
|
||||
Append to `PORTFOLIO.tags`. To make it filter anything, also add it to the `tags: []` array on the relevant tests.
|
||||
Append to `PORTFOLIO.tags`. To make it filter anything, also add it to the `tags: []` array on the relevant tests. Only the first 6 tags are shown by default; the rest hide behind a "+N more" chip.
|
||||
|
||||
### Add an experience entry
|
||||
|
||||
Push to `PORTFOLIO.experience`. The Trace tab parses `when` (`"Aug 2023 – Apr 2026"`) automatically and lays it out on the timeline.
|
||||
|
||||
### Refresh Gitea repo descriptions
|
||||
|
||||
Public repos are listed at [git.levkin.ca/explore/repos](https://git.levkin.ca/explore/repos); the HTTP UI may split results across pages, but the Gitea API returns **all 19 public repos in one `repos/search` response**. To refresh blurbs from live READMEs:
|
||||
|
||||
```bash
|
||||
node scripts/fetch-gitea-repos.mjs
|
||||
```
|
||||
|
||||
Copy the printed `giteaRepos: [ … ]` block into `js/data.js` (or merge rows by hand). Descriptions prefer the repo's Gitea `description` field, then the first paragraph of `README.md`.
|
||||
|
||||
---
|
||||
|
||||
## Customizing the look
|
||||
|
||||
All design tokens live in [`css/base.css`](css/base.css) as CSS variables, scoped to `:root[data-theme='dark']` and `:root[data-theme='light']`.
|
||||
All design tokens live in [`css/base.css`](css/base.css) as CSS variables, scoped to `:root[data-theme='dark']`, `:root[data-theme='light']`, and `:root[data-theme='hc']` (WCAG AAA high-contrast).
|
||||
|
||||
**Want a different accent?** Change `--accent` (default `#4ec9b0`, Playwright's signature teal).
|
||||
|
||||
@ -150,7 +236,7 @@ Any static host works because there's no build:
|
||||
- **Your homelab (Caddy / nginx)** — just serve the directory.
|
||||
- **Custom domain (e.g. `iliadobkin.com`)** — point an A/CNAME record at your host.
|
||||
|
||||
> Note: the theme toggle persists via a cookie, which works fine on any normal domain. If you embed in a sandboxed iframe that strips cookies, the theme will reset on reload but otherwise works.
|
||||
> Note: the theme toggle cycles **dark → light → high-contrast (WCAG AAA)** and persists via a cookie, which works fine on any normal domain. If you embed in a sandboxed iframe that strips cookies, the theme will reset on reload but otherwise works.
|
||||
|
||||
---
|
||||
|
||||
@ -159,9 +245,10 @@ Any static host works because there's no build:
|
||||
**Render loop is simple and stateless per-test:**
|
||||
|
||||
```
|
||||
state[testId] = { status: 'idle' | 'running' | 'passed', runtime: ms }
|
||||
state[testId] = { status: 'idle' | 'running' | 'passed' | 'skipped', runtime: ms }
|
||||
|
||||
runTest(id)
|
||||
├─ if test.skip → log warning, bail
|
||||
├─ flip state to 'running'
|
||||
├─ refreshTreeRow + refreshResultRow (update sidebar + main pane)
|
||||
├─ animate progress bar via requestAnimationFrame
|
||||
@ -174,8 +261,13 @@ runTest(id)
|
||||
| -------------------------------- | --------------------------------- |
|
||||
| What a test looks like inside | `data.js` — the matching `render*` fn |
|
||||
| The order or set of tests | `data.js` — `PORTFOLIO.suite.tests` |
|
||||
| The set of spec files (tabs) | `data.js` — `PORTFOLIO.specs` (+ `spec` on each test) |
|
||||
| The editor bar + overflow menu | `app.js` — `renderEditorStrip()` / `initOverflowMenu()` |
|
||||
| The status pill / summary stripe | `app.js` — `updateStatusbar()` / `updateSummaryStripe()` |
|
||||
| The tag bar + clear button | `app.js` — `renderTagBar()` / `syncTagBarUI()` / `clearTagFilter()` |
|
||||
| The trace tab parsing | `app.js` — `renderTrace()` + `parseMon()` |
|
||||
| The fake source code | `app.js` — `renderSource()` |
|
||||
| The Network / Gitea repo list | `data.js` — `giteaRepos` + `app.js` — `renderNetwork()` |
|
||||
| Run-animation timing | `app.js` — `runTest()` / `tween()`|
|
||||
| Theme colors | `base.css` — `:root[data-theme=*]`|
|
||||
| Mobile breakpoints | `app.css` — `@media (max-width: 900px)` |
|
||||
|
||||
BIN
assets/ilia-dobkin-resume.pdf
Normal file
BIN
assets/ilia-dobkin-resume.pdf
Normal file
Binary file not shown.
528
css/app.css
528
css/app.css
@ -7,10 +7,13 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===================== TOP BAR ===================== */
|
||||
/* ===================== TOP BAR =====================
|
||||
* Two-column layout: brand + crumb on the left, run controls + theme/help on
|
||||
* the right. The status pill + workers/headed toggles moved out of here (down
|
||||
* into the editor bar) so this strip stays focused on global app chrome. */
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
background: var(--topbar-bg);
|
||||
border-bottom: 1px solid var(--line);
|
||||
@ -27,11 +30,12 @@ body {
|
||||
.crumb__sep { color: var(--text-4); padding: 0 6px; }
|
||||
.crumb strong { color: var(--text); font-weight: 500; }
|
||||
|
||||
/* Status pill lives in the editor bar now; height matches the slimmer strip. */
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 10px;
|
||||
height: 24px; padding: 0 12px;
|
||||
background: var(--bg-3); border: 1px solid var(--line);
|
||||
border-radius: 999px; font-family: var(--font-mono); font-size: var(--text-xs);
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 20px; padding: 0 10px;
|
||||
background: var(--bg-3); border: 1px solid var(--line-2);
|
||||
border-radius: 999px; font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-4); display: inline-block; }
|
||||
@ -41,7 +45,8 @@ body {
|
||||
.dot--fail { background: var(--fail); }
|
||||
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(78,201,176,.55);} 70%{box-shadow:0 0 0 8px rgba(78,201,176,0);} 100%{box-shadow:0 0 0 0 rgba(78,201,176,0);} }
|
||||
|
||||
.status-counts { display: inline-flex; gap: 10px; padding-left: 10px; border-left: 1px solid var(--line-2); }
|
||||
.status-counts { display: inline-flex; gap: 8px; padding-left: 8px; border-left: 1px solid var(--line-2); font-variant-numeric: tabular-nums; }
|
||||
.cnt { display: inline-flex; align-items: baseline; gap: 3px; }
|
||||
.cnt em { font-style: normal; font-weight: 600; }
|
||||
.cnt--pass { color: var(--pass); }
|
||||
.cnt--fail { color: var(--fail); }
|
||||
@ -60,6 +65,7 @@ body {
|
||||
background: var(--pass); color: #0b1f1a; border-color: transparent; font-weight: 600;
|
||||
}
|
||||
.btn--run:hover { background: var(--pass-2); }
|
||||
:root[data-theme='hc'] .btn--run { color: #000000; }
|
||||
.btn--ghost { background: transparent; border-color: transparent; color: var(--text-2); }
|
||||
.btn--ghost:hover { background: var(--hover); color: var(--text); }
|
||||
.iconbtn { background: transparent; border: 0; color: var(--text-3); cursor: pointer; padding: 4px; border-radius: 3px; }
|
||||
@ -67,11 +73,26 @@ body {
|
||||
|
||||
.toggle { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); color: var(--text-3); font-size: var(--text-xs); user-select: none; cursor: pointer; }
|
||||
.toggle input { accent-color: var(--accent); }
|
||||
.toggle.workers { gap: 0; }
|
||||
.toggle.workers select {
|
||||
appearance: none;
|
||||
background: var(--bg-3); color: var(--text);
|
||||
border: 1px solid var(--line-2); border-radius: 3px;
|
||||
font-family: var(--font-mono); font-size: var(--text-xs);
|
||||
padding: 1px 16px 1px 4px; margin-left: 2px;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||
background-position: calc(100% - 9px) 50%, calc(100% - 5px) 50%;
|
||||
background-size: 4px 4px, 4px 4px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.toggle.workers select:hover { border-color: var(--text-3); color: var(--text); }
|
||||
.toggle.workers select:focus { outline: 0; border-color: var(--accent); color: var(--text); }
|
||||
|
||||
/* ===================== LAYOUT ===================== */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
grid-template-columns: 400px 1fr;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@ -117,15 +138,107 @@ body {
|
||||
.tag:hover { background: var(--hover); }
|
||||
.tag.is-active { background: var(--accent); color: #0b1f1a; }
|
||||
:root[data-theme='light'] .tag.is-active { color: #fff; }
|
||||
:root[data-theme='hc'] .tag.is-active { color: #000000; }
|
||||
/* Tag bar shows the first 6 tags by default; "+N more" expands the rest
|
||||
* inline so the visual mass stays small until a visitor needs the full list. */
|
||||
.tag.is-collapsed { display: none; }
|
||||
.tags.is-expanded .tag.is-collapsed { display: inline-flex; }
|
||||
.tags-more {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
font-family: var(--font-mono); font-size: 10.5px;
|
||||
background: transparent; color: var(--text-3);
|
||||
padding: 1px 7px; border-radius: 3px; cursor: pointer;
|
||||
border: 1px dashed var(--line-2);
|
||||
transition: color .12s, border-color .12s, background .12s;
|
||||
}
|
||||
.tags-more:hover { color: var(--text); border-color: var(--text-4); background: var(--hover); }
|
||||
.tags.is-expanded .tags-more { display: none; }
|
||||
.tags-clear {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
font-family: var(--font-mono); font-size: 10px;
|
||||
background: transparent; color: var(--text-4);
|
||||
padding: 1px 6px; border-radius: 3px; cursor: default;
|
||||
border: 1px solid transparent;
|
||||
opacity: .4;
|
||||
pointer-events: none;
|
||||
transition: opacity .12s, background .12s, color .12s;
|
||||
}
|
||||
.tags-clear svg { width: 10px; height: 10px; }
|
||||
.tags.has-active .tags-clear {
|
||||
color: var(--fail); opacity: .85; cursor: pointer; pointer-events: auto;
|
||||
}
|
||||
.tags.has-active .tags-clear:hover { opacity: 1; background: var(--hover); }
|
||||
|
||||
.tree { flex: 1; overflow: auto; padding: 4px 0 16px; font-size: var(--text-sm); }
|
||||
.suite { padding: 6px 10px 2px; }
|
||||
.suite__head { display: flex; align-items: center; gap: 6px; color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs); }
|
||||
.suite__caret { width: 12px; display: inline-block; transition: transform .15s; }
|
||||
.suite.collapsed .suite__caret { transform: rotate(-90deg); }
|
||||
.suite + .suite { margin-top: 4px; padding-top: 8px; border-top: 1px dashed var(--line); }
|
||||
.suite__head {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-xs);
|
||||
cursor: pointer;
|
||||
padding: 2px 4px; margin: 0 -4px;
|
||||
border-radius: 3px;
|
||||
transition: background .12s var(--ease);
|
||||
}
|
||||
.suite__head:hover { background: var(--hover); }
|
||||
.suite.is-active > .suite__head { background: rgba(78,201,176,.06); }
|
||||
:root[data-theme='light'] .suite.is-active > .suite__head { background: rgba(12,138,111,.07); }
|
||||
/* Caret defaults to "expanded" (▾ pointing down). Collapsed rows snap back
|
||||
* to the natural ▸ — matches VS Code's Test Explorer and is what visitors
|
||||
* intuit on first glance. */
|
||||
.suite__caret {
|
||||
width: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 14px;
|
||||
color: var(--text-3);
|
||||
transition: transform .15s var(--ease);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.suite__caret svg { width: 12px; height: 12px; display: block; }
|
||||
.suite.collapsed .suite__caret { transform: rotate(0deg); }
|
||||
.suite.collapsed .test { display: none; }
|
||||
.suite__name { color: var(--text-2); }
|
||||
/* Suite name truncates with ellipsis instead of wrapping — a wrapped row in
|
||||
* a tree view always reads as a layout bug, even when intentional. */
|
||||
.suite__name {
|
||||
color: var(--text-2);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.suite__name em { color: var(--accent); font-style: normal; }
|
||||
/* Counts are "metrics" — right-aligned, fixed min-width, tabular numerals,
|
||||
* outlined box rather than the rounded pill we use for @tags. */
|
||||
.suite__count {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 22px;
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-3);
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
:root[data-theme='light'] .suite__count { background: rgba(0,0,0,.03); }
|
||||
.suite.is-active .suite__count {
|
||||
color: var(--text);
|
||||
background: rgba(78,201,176,.12);
|
||||
border-color: rgba(78,201,176,.35);
|
||||
}
|
||||
:root[data-theme='light'] .suite.is-active .suite__count {
|
||||
background: rgba(12,138,111,.10);
|
||||
border-color: rgba(12,138,111,.30);
|
||||
}
|
||||
|
||||
.test {
|
||||
display: grid;
|
||||
@ -139,6 +252,8 @@ body {
|
||||
.test:hover { background: var(--hover); }
|
||||
.test.is-selected { background: var(--active); border-left-color: var(--accent); }
|
||||
.test.is-running { background: var(--hover); }
|
||||
.test.is-skipped { opacity: .55; }
|
||||
.test.is-skipped .test__run { visibility: hidden; }
|
||||
.test__run {
|
||||
width: 18px; height: 18px; border-radius: 3px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@ -155,6 +270,7 @@ body {
|
||||
.icon--run { color: var(--accent); }
|
||||
.icon--pass { color: var(--pass); }
|
||||
.icon--fail { color: var(--fail); }
|
||||
.icon--skip { color: var(--skip); }
|
||||
|
||||
.spin { animation: spin 1s linear infinite; transform-origin: center; }
|
||||
@keyframes spin { from{transform:rotate(0)} to{transform:rotate(360deg)} }
|
||||
@ -163,6 +279,7 @@ body {
|
||||
.test__title span.kw { color: var(--info); }
|
||||
.test__title span.str { color: #ce9178; }
|
||||
:root[data-theme='light'] .test__title span.str { color: #a31515; }
|
||||
:root[data-theme='hc'] .test__title span.str { color: #ffd700; }
|
||||
|
||||
.test__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
|
||||
|
||||
@ -175,7 +292,150 @@ body {
|
||||
}
|
||||
|
||||
/* ===================== MAIN PANE ===================== */
|
||||
.main { display: grid; grid-template-rows: 34px 1fr; min-height: 0; background: var(--bg); }
|
||||
.main { display: grid; grid-template-rows: 28px 34px 1fr; min-height: 0; background: var(--bg); }
|
||||
|
||||
/* ----- EDITOR BAR (spec tabs + status pill + overflow menu) -----
|
||||
* The bar itself fits in 28px (slimmer than VS Code's ~35px tab strip to
|
||||
* compensate for our smaller font size). It hosts:
|
||||
* 1. .editor-strip — scrollable list of open .spec.ts files
|
||||
* 2. .editor-bar__right — sticky status pill + a "⋯" overflow that
|
||||
* exposes --workers= and --headed (used to live in the top bar). */
|
||||
.editor-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: var(--bg-2);
|
||||
border-bottom: 1px solid var(--line);
|
||||
min-height: 0;
|
||||
}
|
||||
.editor-strip {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.editor-strip::-webkit-scrollbar { height: 0; }
|
||||
.editor-bar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 8px 0 10px;
|
||||
border-left: 1px solid var(--line);
|
||||
background: var(--bg-2);
|
||||
}
|
||||
.espec {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
color: var(--text-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .12s var(--ease), color .12s;
|
||||
}
|
||||
.espec:hover { background: var(--hover); color: var(--text); }
|
||||
.espec.is-active { background: var(--bg); color: var(--text); }
|
||||
.espec.is-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
/* TS file-type badge — 1–2px tighter than before so the slimmer tabs don't
|
||||
* end up dominated by the icon. */
|
||||
.espec__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px; height: 14px;
|
||||
background: #3178c6;
|
||||
color: #fff;
|
||||
border-radius: 2px;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
:root[data-theme='light'] .espec__icon { background: #2168bd; }
|
||||
:root[data-theme='hc'] .espec__icon { background: #ffff00; color: #000000; }
|
||||
.espec__name { /* filename — inherits .espec font */ }
|
||||
.espec__count {
|
||||
/* "metric" treatment: tabular numerals, fixed min-width, right-aligned,
|
||||
* subtle outline so it reads as a number rather than another tag. */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 18px;
|
||||
height: 16px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-3);
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 3px;
|
||||
line-height: 1;
|
||||
}
|
||||
:root[data-theme='light'] .espec__count { background: rgba(0,0,0,.03); }
|
||||
.espec.is-active .espec__count {
|
||||
color: var(--text);
|
||||
background: rgba(78,201,176,.12);
|
||||
border-color: rgba(78,201,176,.35);
|
||||
}
|
||||
:root[data-theme='light'] .espec.is-active .espec__count {
|
||||
background: rgba(12,138,111,.10);
|
||||
border-color: rgba(12,138,111,.30);
|
||||
}
|
||||
|
||||
/* ----- OVERFLOW MENU (⋯) -----
|
||||
* Dropdown anchored to the bar; clicks outside close it (handled in app.js).
|
||||
* Holds --workers= and --headed so the editor bar stays uncluttered. */
|
||||
.overflow { position: relative; }
|
||||
.overflow__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px; height: 22px;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background .12s var(--ease), color .12s, border-color .12s;
|
||||
}
|
||||
.overflow__btn:hover { background: var(--hover); color: var(--text); }
|
||||
.overflow.is-open .overflow__btn {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-color: var(--line-2);
|
||||
}
|
||||
.overflow__menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 180px;
|
||||
animation: ksAppear .12s var(--ease);
|
||||
}
|
||||
.overflow__menu[hidden] { display: none; }
|
||||
.overflow__menu .toggle { font-size: var(--text-xs); }
|
||||
|
||||
.tabs {
|
||||
display: flex; align-items: stretch;
|
||||
background: var(--bg-2);
|
||||
@ -191,19 +451,51 @@ body {
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.is-active { color: var(--text); background: var(--bg); border-top-color: var(--accent); }
|
||||
.tabs__spacer { flex: 1; }
|
||||
.tabs__meta { display: flex; align-items: center; padding: 0 14px; color: var(--text-4); font-family: var(--font-mono); font-size: 10.5px; }
|
||||
|
||||
.pane { display: none; height: 100%; overflow: auto; padding: 0; }
|
||||
.pane.is-active { display: block; }
|
||||
|
||||
/* HERO */
|
||||
.hero { padding: 36px 36px 18px; border-bottom: 1px solid var(--line); }
|
||||
.hero { padding: 36px 36px 18px; border-bottom: 1px solid var(--line); transition: padding .15s var(--ease); }
|
||||
.hero__tag { display: inline-block; font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; color: var(--accent); background: rgba(78,201,176,.1); padding: 2px 8px; border-radius: 3px; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.hero__title { margin: 0; font-size: clamp(34px, 4vw, 52px); font-weight: 700; letter-spacing: -.02em; line-height: 1.05; }
|
||||
.hero__sub { color: var(--text-3); font-family: var(--font-mono); font-size: var(--text-sm); margin: 8px 0 0; }
|
||||
.hero__hint { color: var(--text-3); margin: 18px 0 0; font-size: var(--text-sm); }
|
||||
/* Slim variant for non-portfolio specs — keeps the runner aesthetic but
|
||||
stops a 52px hero from drowning a 1-test spec like playground.spec.ts. */
|
||||
.hero--slim { padding: 22px 36px 14px; }
|
||||
.hero--slim .hero__tag { margin-bottom: 8px; }
|
||||
.hero--slim .hero__title { font-size: clamp(22px, 2.4vw, 28px); }
|
||||
.hero--slim .hero__sub { font-size: var(--text-xs); margin-top: 4px; }
|
||||
.hero--slim .hero__hint { margin-top: 10px; font-size: var(--text-xs); }
|
||||
.kbd { font-family: var(--font-mono); background: var(--kbd-bg); border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 3px; padding: 1px 6px; font-size: 11px; color: var(--text); }
|
||||
|
||||
/* Tiny run-history banner above the results list. Pulls the eye to dynamic
|
||||
* data — runtime + counts — while taking only one line of vertical space. */
|
||||
.summary-stripe {
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 36px;
|
||||
font-family: var(--font-mono); font-size: 11px;
|
||||
color: var(--text-3);
|
||||
background: linear-gradient(to bottom, rgba(78,201,176,.03), transparent);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.ss__lbl { color: var(--text-4); letter-spacing: .06em; text-transform: uppercase; font-size: 10px; }
|
||||
.ss__sep { color: var(--text-4); }
|
||||
.ss__val { color: var(--text-2); font-variant-numeric: tabular-nums; }
|
||||
.ss__val--pass { color: var(--pass); }
|
||||
.ss__val--pending { color: var(--skip); }
|
||||
.ss__val--skip { color: var(--skip); opacity: .7; }
|
||||
.ss__val--idle { color: var(--text-4); }
|
||||
.ss__dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; display: inline-block;
|
||||
background: var(--text-4); margin-right: 4px;
|
||||
}
|
||||
.ss__dot--pass { background: var(--pass); }
|
||||
.ss__dot--fail { background: var(--fail); }
|
||||
.ss__dot--pending { background: var(--skip); }
|
||||
.ss__dot--running { background: var(--accent); animation: pulse 1.2s infinite; }
|
||||
|
||||
/* RESULTS list */
|
||||
.results { padding: 12px 0 80px; }
|
||||
|
||||
@ -226,14 +518,58 @@ body {
|
||||
.result__title .kw { color: var(--info); }
|
||||
.result__title .str { color: #ce9178; }
|
||||
:root[data-theme='light'] .result__title .str { color: #a31515; }
|
||||
:root[data-theme='hc'] .result__title .str { color: #ffd700; }
|
||||
.result__dur { color: var(--text-4); font-family: var(--font-mono); font-size: 11px; }
|
||||
.result__rerun { color: var(--text-3); background: transparent; border: 0; cursor: pointer; padding: 4px; border-radius: 3px; }
|
||||
.result__rerun:hover { background: var(--hover); color: var(--text); }
|
||||
|
||||
.result__body { display: none; padding: 4px 0 24px 46px; animation: slideDown .25s var(--ease); }
|
||||
.result.is-open .result__body { display: block; }
|
||||
.result.is-skipped { opacity: .65; }
|
||||
.result.is-skipped .result__rerun { visibility: hidden; }
|
||||
@keyframes slideDown { from { opacity: 0; transform: translateY(-4px);} to { opacity: 1; transform: none; } }
|
||||
|
||||
/* "Pending" preview — the first idle test is shown expanded with its body
|
||||
* pre-rendered so visitors see what they'd get without committing to a run.
|
||||
* Faded look + a centered "click ▶ to run" overlay reads as "preview, not
|
||||
* result". Click anywhere on the overlay to kick off the run. */
|
||||
.result.is-pending {
|
||||
position: relative;
|
||||
}
|
||||
.result.is-pending .result__body {
|
||||
display: block;
|
||||
position: relative;
|
||||
opacity: .42;
|
||||
filter: saturate(.7);
|
||||
pointer-events: none;
|
||||
animation: none;
|
||||
}
|
||||
.result__pending-overlay {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 48%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 7px 14px;
|
||||
font-family: var(--font-mono); font-size: 11.5px;
|
||||
color: var(--text-2);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
z-index: 2;
|
||||
white-space: nowrap;
|
||||
transition: background .12s var(--ease), transform .15s var(--ease);
|
||||
}
|
||||
.result__pending-overlay:hover {
|
||||
background: var(--bg-3);
|
||||
transform: translate(-50%, -50%) translateY(-1px);
|
||||
}
|
||||
.result__pending-overlay .kbd { font-size: 10.5px; padding: 0 5px; }
|
||||
.result.is-pending .result__caret { opacity: .6; }
|
||||
|
||||
.progress {
|
||||
height: 2px; width: 100%; background: transparent; overflow: hidden; position: relative;
|
||||
margin: -2px 0 0;
|
||||
@ -254,6 +590,8 @@ body {
|
||||
padding-left: 14px;
|
||||
}
|
||||
.step__icon { color: var(--pass); }
|
||||
.step__icon.is-fail { color: var(--fail); }
|
||||
.step__icon.is-pass { color: var(--pass); }
|
||||
.step__icon.is-skip { color: var(--skip); }
|
||||
.step__icon.is-info { color: var(--info); }
|
||||
.step__dur { color: var(--text-4); }
|
||||
@ -284,6 +622,10 @@ body {
|
||||
.snippet .code .str { color: #ce9178; }
|
||||
.snippet .code .num { color: #b5cea8; }
|
||||
.snippet .code .cm { color: #6a9955; font-style: italic; }
|
||||
:root[data-theme='hc'] .snippet .code .fn { color: #ffff00; }
|
||||
:root[data-theme='hc'] .snippet .code .str { color: #ffd700; }
|
||||
:root[data-theme='hc'] .snippet .code .num { color: #00ff00; }
|
||||
:root[data-theme='hc'] .snippet .code .cm { color: #7fd5ff; }
|
||||
.snippet .code .tag { color: var(--accent); }
|
||||
:root[data-theme='light'] .snippet .code .str { color: #a31515; }
|
||||
:root[data-theme='light'] .snippet .code .fn { color: #795e26; }
|
||||
@ -334,6 +676,16 @@ body {
|
||||
.cta--ghost { background: transparent; color: var(--text); border: 1px solid var(--line-2); }
|
||||
:root[data-theme='light'] .cta { color: #ffffff; }
|
||||
:root[data-theme='light'] .cta--ghost { color: var(--text); }
|
||||
:root[data-theme='hc'] .cta { color: #000000; }
|
||||
:root[data-theme='hc'] .cta--ghost { color: var(--text); }
|
||||
|
||||
.projects-foot { margin-top: 18px; font-size: 12px; color: var(--text-3); line-height: 1.55; max-width: 52rem; }
|
||||
.projects-foot .tab-link {
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
color: var(--info); font-family: inherit; font-size: inherit;
|
||||
cursor: pointer; text-decoration: underline;
|
||||
}
|
||||
.projects-foot .tab-link:hover { color: var(--accent); }
|
||||
|
||||
/* ===================== TRACE PANE ===================== */
|
||||
.trace-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 24px; border-bottom: 1px solid var(--line); }
|
||||
@ -355,6 +707,54 @@ body {
|
||||
.trace-axis { display: grid; grid-template-columns: 220px 1fr 90px; gap: 12px; padding: 6px 24px 16px; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-4); }
|
||||
.trace-axis__ticks { display: flex; justify-content: space-between; }
|
||||
|
||||
/* ===================== NETWORK PANE ===================== */
|
||||
/* Block layout (not nested flex-shrink) — long repo list stays visible everywhere. */
|
||||
.network-pane { padding: 0; min-height: 40vh; }
|
||||
.network-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 24px; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.network-head__title { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--text); }
|
||||
.network-head__meta { display: flex; gap: 14px; font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
|
||||
.network-scroll { padding: 0 0 32px; max-height: none; overflow: visible; -webkit-overflow-scrolling: touch; }
|
||||
.network-empty, .network-err {
|
||||
padding: 20px 24px; font-size: var(--text-xs); color: var(--text-3); line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.network-err { color: var(--skip); }
|
||||
.network-entry { border-bottom: 1px solid var(--line); }
|
||||
.network-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) 36px 38px 56px 48px 18px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 24px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.network-row:hover { background: var(--hover); }
|
||||
.network-row__method { color: var(--info); font-weight: 600; }
|
||||
.network-row__url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
.network-row__status { color: var(--pass); }
|
||||
.network-row__caret { display: flex; align-items: center; justify-content: center; color: var(--text-4); }
|
||||
.network-row__caret svg { transition: transform .15s var(--ease); }
|
||||
.network-entry.is-open .network-row__caret svg { transform: rotate(90deg); }
|
||||
.network-detail { padding: 0 24px 14px 44px; background: var(--bg-2); border-top: 1px solid var(--line); }
|
||||
.network-detail__hdr { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-4); margin: 10px 0 6px; }
|
||||
.network-detail__body {
|
||||
margin: 0; padding: 12px 14px;
|
||||
background: var(--panel); border: 1px solid var(--line-2); border-radius: var(--radius-sm);
|
||||
font-size: 11px; line-height: 1.45; overflow-x: auto; white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.network-detail__link { display: inline-block; margin-top: 10px; font-size: var(--text-xs); color: var(--info); }
|
||||
.network-detail__link:hover { text-decoration: underline; }
|
||||
|
||||
/* ===================== SOURCE PANE ===================== */
|
||||
.source { font-family: var(--font-mono); font-size: var(--text-xs); padding: 14px 0; line-height: 1.65; }
|
||||
.source__line { display: grid; grid-template-columns: 50px 1fr; gap: 14px; padding: 0 24px 0 0; }
|
||||
@ -366,6 +766,10 @@ body {
|
||||
.source .str { color: #ce9178; }
|
||||
.source .num { color: #b5cea8; }
|
||||
.source .cm { color: #6a9955; font-style: italic; }
|
||||
:root[data-theme='hc'] .source .fn { color: #ffff00; }
|
||||
:root[data-theme='hc'] .source .str { color: #ffd700; }
|
||||
:root[data-theme='hc'] .source .num { color: #00ff00; }
|
||||
:root[data-theme='hc'] .source .cm { color: #7fd5ff; }
|
||||
.source .tag { color: var(--accent); }
|
||||
:root[data-theme='light'] .source .str { color: #a31515; }
|
||||
:root[data-theme='light'] .source .fn { color: #795e26; }
|
||||
@ -406,9 +810,7 @@ body {
|
||||
@media (max-width: 900px) {
|
||||
body { grid-template-rows: 44px 1fr 22px; overflow: auto; }
|
||||
.topbar { grid-template-columns: auto 1fr auto; padding: 0 8px; gap: 6px; }
|
||||
.topbar__center { display: none; }
|
||||
.topbar__left .crumb { display: none; }
|
||||
.topbar__right .toggle { display: none; }
|
||||
.topbar__right { gap: 4px; }
|
||||
.btn--run span { display: none; }
|
||||
.btn { padding: 0 8px; }
|
||||
@ -417,7 +819,7 @@ body {
|
||||
.layout { grid-template-columns: 1fr; min-height: 0; }
|
||||
.sidebar {
|
||||
position: fixed; top: 44px; bottom: 22px; left: 0;
|
||||
width: 86%; max-width: 320px; z-index: 50;
|
||||
width: 86%; max-width: 400px; z-index: 50;
|
||||
transform: translateX(-100%); transition: transform .25s var(--ease);
|
||||
box-shadow: 4px 0 12px rgba(0,0,0,.4);
|
||||
}
|
||||
@ -429,12 +831,22 @@ body {
|
||||
.sidebar-scrim.is-open { display: block; }
|
||||
|
||||
.tabs { overflow-x: auto; }
|
||||
.tabs__meta { display: none; }
|
||||
|
||||
.editor-strip { font-size: 10.5px; }
|
||||
.espec { padding: 0 10px; gap: 6px; }
|
||||
.espec__count { display: none; }
|
||||
.espec__icon { width: 14px; height: 12px; font-size: 8px; }
|
||||
.editor-bar__right { padding: 0 6px; gap: 6px; }
|
||||
.status-pill { padding: 0 8px; gap: 6px; }
|
||||
.status-counts { padding-left: 6px; gap: 6px; }
|
||||
.summary-stripe { padding: 6px 16px; font-size: 10.5px; }
|
||||
|
||||
.hero { padding: 22px 18px 14px; }
|
||||
.hero__title { font-size: 32px; }
|
||||
.hero__sub { font-size: 12px; word-break: break-word; }
|
||||
.hero__hint { font-size: 12.5px; }
|
||||
.hero--slim { padding: 14px 18px 10px; }
|
||||
.hero--slim .hero__title { font-size: 20px; }
|
||||
|
||||
.result { padding: 0 16px; }
|
||||
.result__head { grid-template-columns: 16px 16px 1fr auto; gap: 8px; }
|
||||
@ -444,6 +856,16 @@ body {
|
||||
.step { grid-template-columns: 16px 1fr auto; font-size: 11px; }
|
||||
.step__title { word-break: break-word; white-space: normal; }
|
||||
|
||||
.network-row {
|
||||
grid-template-columns: 38px minmax(0, 1fr) 32px 44px 16px;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.network-row__mime,
|
||||
.network-row__size { display: none; }
|
||||
.network-detail { padding-left: 16px; padding-right: 16px; }
|
||||
|
||||
.trace-row, .trace-axis { grid-template-columns: 130px 1fr 56px; gap: 8px; font-size: 10.5px; }
|
||||
.trace-row__label small { display: none; }
|
||||
|
||||
@ -453,3 +875,71 @@ body {
|
||||
.statusbar { font-size: 10.5px; gap: 8px; }
|
||||
.statusbar .sb__seg:nth-child(n+6) { display: none; }
|
||||
}
|
||||
|
||||
/* ===================== KEYBOARD SHORTCUTS OVERLAY ===================== */
|
||||
.kshelp[hidden] { display: none; }
|
||||
.kshelp {
|
||||
position: fixed; inset: 0; z-index: 100;
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
.kshelp__scrim {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
backdrop-filter: blur(2px);
|
||||
animation: ksFade .12s var(--ease);
|
||||
}
|
||||
.kshelp__panel {
|
||||
position: relative;
|
||||
width: min(520px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
outline: 0;
|
||||
animation: ksAppear .15s var(--ease);
|
||||
}
|
||||
@keyframes ksFade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes ksAppear { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
|
||||
|
||||
.kshelp__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.kshelp__title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: .12em; text-transform: uppercase;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.kshelp__title::before { content: '// '; color: var(--text-4); }
|
||||
.kshelp__close {
|
||||
background: transparent; border: 0; color: var(--text-3);
|
||||
font-size: 20px; line-height: 1; cursor: pointer;
|
||||
padding: 2px 8px; border-radius: 3px;
|
||||
}
|
||||
.kshelp__close:hover { background: var(--hover); color: var(--text); }
|
||||
|
||||
.kshelp__grid { padding: 4px 0; }
|
||||
.kshelp__row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 14px; align-items: center;
|
||||
padding: 9px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
.kshelp__row + .kshelp__row { border-top: 1px dashed var(--line); }
|
||||
.kshelp__keys { display: inline-flex; gap: 4px; align-items: center; color: var(--text-3); }
|
||||
.kshelp__desc { color: var(--text-2); }
|
||||
|
||||
.kshelp__foot {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-3); font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kshelp__panel { width: 92vw; }
|
||||
.kshelp__row { grid-template-columns: 110px 1fr; padding: 8px 14px; }
|
||||
}
|
||||
|
||||
45
css/base.css
45
css/base.css
@ -88,6 +88,51 @@
|
||||
--statusbar-fg: #ffffff;
|
||||
}
|
||||
|
||||
/* High Contrast — WCAG AAA (every text token ≥ 7:1 against --bg).
|
||||
* Colors picked from the Windows / VS Code HC palette: pure black canvas,
|
||||
* pure-white prose, yellow as the universal accent, lime/red/cyan for
|
||||
* pass/fail/info so the three are still distinguishable for protanopia. */
|
||||
:root[data-theme='hc'] {
|
||||
--bg: #000000;
|
||||
--bg-2: #000000;
|
||||
--bg-3: #0a0a0a;
|
||||
--panel: #000000;
|
||||
--panel-2: #0a0a0a;
|
||||
--line: #ffffff;
|
||||
--line-2: #ffffff;
|
||||
--text: #ffffff; /* 21:1 */
|
||||
--text-2: #ffffff; /* 21:1 — no muting; AAA prose */
|
||||
--text-3: #e6e6e6; /* 18:1 */
|
||||
--text-4: #c0c0c0; /* 12.6:1 */
|
||||
--accent: #ffff00; /* 19.6:1 — the one yellow */
|
||||
--pass: #00ff00; /* 15.3:1 */
|
||||
--pass-2: #00ff00;
|
||||
--fail: #ff8585; /* 9.5:1 */
|
||||
--skip: #ffd700; /* 16.4:1 */
|
||||
--info: #7fd5ff; /* 11.7:1 */
|
||||
--run: #00ff00;
|
||||
--selection:#ffff00; /* paired with black text via override below */
|
||||
--shadow: 0 0 0 1px #ffffff;
|
||||
--hover: rgba(255,255,255,.18);
|
||||
--active: rgba(255,255,255,.30);
|
||||
--kbd-bg: #000000;
|
||||
--tag-bg: #000000;
|
||||
--tag-fg: #ffff00;
|
||||
--green-arrow: #00ff00;
|
||||
--topbar-bg: #000000;
|
||||
--statusbar-bg: #ffff00;
|
||||
--statusbar-fg: #000000;
|
||||
}
|
||||
:root[data-theme='hc'] ::selection { background: #ffff00; color: #000000; }
|
||||
|
||||
/* AAA also requires every interactive control to expose a visible focus
|
||||
* indicator; force a 3px yellow outline on every focused element in HC,
|
||||
* overriding the `outline: 0` declarations sprinkled through app.css. */
|
||||
:root[data-theme='hc'] :focus-visible {
|
||||
outline: 3px solid #ffff00 !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
|
||||
102
index.html
102
index.html
@ -9,8 +9,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="css/base.css" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<link rel="stylesheet" href="css/base.css?v=7" />
|
||||
<link rel="stylesheet" href="css/app.css?v=7" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- ============== TOP BAR ============== -->
|
||||
@ -32,18 +32,6 @@
|
||||
<span class="crumb"><span class="crumb__sep">/</span> tests <span class="crumb__sep">/</span> <strong>portfolio.spec.ts</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="topbar__center">
|
||||
<div class="status-pill" id="status-pill">
|
||||
<span class="dot dot--idle" id="status-dot"></span>
|
||||
<span id="status-text">idle</span>
|
||||
<span class="status-counts">
|
||||
<span class="cnt cnt--pass" title="passed">✓ <em id="cnt-pass">0</em></span>
|
||||
<span class="cnt cnt--fail" title="failed">✗ <em id="cnt-fail">0</em></span>
|
||||
<span class="cnt cnt--skip" title="skipped">⊘ <em id="cnt-skip">0</em></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar__right">
|
||||
<button class="btn btn--run" id="run-all" title="Run all tests">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M4 3 L13 8 L4 13 Z" fill="currentColor"/></svg>
|
||||
@ -55,13 +43,16 @@
|
||||
<button class="btn btn--ghost" id="reset-all" title="Reset">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 3.5a4.5 4.5 0 1 1-4.39 5.5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/><path d="M8 1 L8 5 L4 5" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<label class="toggle" title="Headed mode (slower animations)">
|
||||
<input type="checkbox" id="headed" />
|
||||
<span>--headed</span>
|
||||
</label>
|
||||
<button class="btn btn--ghost" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||
<button class="btn btn--ghost" id="theme-toggle" title="Toggle theme (T)" aria-label="Toggle theme">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M11 8a3 3 0 1 1-3-3 3 3 0 0 0 3 3z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button class="btn btn--ghost" id="kshelp-open" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.4" stroke="currentColor" stroke-width="1.4" fill="none"/>
|
||||
<path d="M6 6.4c.1-1.2 1-2 2.1-2s2 .8 2 1.9c0 .9-.6 1.4-1.4 1.8-.5.3-.7.6-.7 1.1v.3" stroke="currentColor" stroke-width="1.4" fill="none" stroke-linecap="round"/>
|
||||
<circle cx="8" cy="11.6" r=".75" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -85,19 +76,57 @@
|
||||
<nav class="tree" id="tree" aria-label="Tests"></nav>
|
||||
|
||||
<div class="sidebar__foot">
|
||||
<span>v1.0.0 · 9 tests</span>
|
||||
<span id="sidebar-foot-meta">v1.0.0 · 12 tests</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Pane -->
|
||||
<section class="main" aria-label="Test results">
|
||||
<!-- Editor bar: spec tabs on the left, status pill + overflow menu on the right.
|
||||
The status pill (counts for the active spec) and the small "⋯" menu
|
||||
(workers / headed) used to live in the top bar; they were merged here
|
||||
to slim that strip and keep run state visually anchored to the editor. -->
|
||||
<div class="editor-bar">
|
||||
<div class="editor-strip" id="editor-strip" role="tablist" aria-label="Open spec files"></div>
|
||||
<div class="editor-bar__right">
|
||||
<div class="status-pill" id="status-pill" aria-live="polite">
|
||||
<span class="dot dot--idle" id="status-dot"></span>
|
||||
<span id="status-text">idle</span>
|
||||
<span class="status-counts">
|
||||
<span class="cnt cnt--pass" title="passed">✓ <em id="cnt-pass">0</em></span>
|
||||
<span class="cnt cnt--fail" title="failed">✗ <em id="cnt-fail">0</em></span>
|
||||
<span class="cnt cnt--skip" title="skipped">⊘ <em id="cnt-skip">0</em></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="overflow" id="overflow">
|
||||
<button class="overflow__btn" id="overflow-btn" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Run options">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="3.4" cy="8" r="1.3" fill="currentColor"/><circle cx="8" cy="8" r="1.3" fill="currentColor"/><circle cx="12.6" cy="8" r="1.3" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<div class="overflow__menu" id="overflow-menu" role="menu" hidden>
|
||||
<label class="toggle workers" title="Workers — number of tests to run in parallel">
|
||||
<span>--workers=</span>
|
||||
<select id="workers" aria-label="Workers">
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="4">4</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle" title="Headed mode (slower animations)">
|
||||
<input type="checkbox" id="headed" />
|
||||
<span>--headed</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab is-active" data-tab="report" role="tab">Report</button>
|
||||
<button class="tab" data-tab="trace" role="tab">Trace</button>
|
||||
<button class="tab" data-tab="source" role="tab">Source</button>
|
||||
<button class="tab" data-tab="network" role="tab">Network</button>
|
||||
<button class="tab" data-tab="console" role="tab">Console</button>
|
||||
<div class="tabs__spacer"></div>
|
||||
<span class="tabs__meta" id="tabs-meta">portfolio.spec.ts · 9 tests</span>
|
||||
</div>
|
||||
|
||||
<!-- Report -->
|
||||
@ -108,6 +137,9 @@
|
||||
<p class="hero__sub">Senior SDET · Toronto, ON · <span class="mono">test.describe("portfolio")</span></p>
|
||||
<p class="hero__hint">Click the green <span class="kbd">▶</span> next to any test to run it — or press <span class="kbd">Run all</span> above.</p>
|
||||
</div>
|
||||
<!-- Tiny run-history banner above the results list. Pulls the eye to
|
||||
dynamic data (e.g. "Last run · 1.4s · 2 passed · 2 pending"). -->
|
||||
<div class="summary-stripe" id="summary-stripe" aria-live="polite"></div>
|
||||
<div class="results" id="results"></div>
|
||||
</div>
|
||||
|
||||
@ -127,6 +159,17 @@
|
||||
<div class="source" id="source"></div>
|
||||
</div>
|
||||
|
||||
<!-- Network (Gitea repos as Playwright-style requests) -->
|
||||
<div class="pane" id="pane-network" role="tabpanel">
|
||||
<div class="network-pane">
|
||||
<div class="network-head">
|
||||
<div class="network-head__title">Network · git.levkin.ca</div>
|
||||
<div class="network-head__meta"><span class="legend"><i class="sw sw--pass"></i> 200</span><span class="legend mono">application/json</span></div>
|
||||
</div>
|
||||
<div class="network-scroll" id="gitea-network" data-repo-list="1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Console -->
|
||||
<div class="pane" id="pane-console" role="tabpanel">
|
||||
<div class="console" id="console"></div>
|
||||
@ -146,7 +189,20 @@
|
||||
<span class="sb__seg sb__seg--accent">Playwright 1.49</span>
|
||||
</footer>
|
||||
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
<!-- ============== KEYBOARD SHORTCUTS OVERLAY ============== -->
|
||||
<div class="kshelp" id="kshelp" hidden role="dialog" aria-modal="true" aria-labelledby="kshelp-title">
|
||||
<div class="kshelp__scrim" id="kshelp-scrim"></div>
|
||||
<div class="kshelp__panel" tabindex="-1">
|
||||
<div class="kshelp__head">
|
||||
<span class="kshelp__title" id="kshelp-title">Keyboard shortcuts</span>
|
||||
<button class="kshelp__close" id="kshelp-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="kshelp__grid" id="kshelp-grid"></div>
|
||||
<div class="kshelp__foot mono"><span class="kbd">?</span> toggles · <span class="kbd">Esc</span> closes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js?v=7"></script>
|
||||
<script src="js/app.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
181
js/data.js
181
js/data.js
@ -6,7 +6,6 @@ window.PORTFOLIO = {
|
||||
title: "Senior SDET",
|
||||
location: "Toronto, Ontario, Canada",
|
||||
email: "idobkin@gmail.com",
|
||||
phone: "+1 (647) 987-2792",
|
||||
linkedin: "https://www.linkedin.com/in/idobkin/",
|
||||
gitea: "https://git.levkin.ca",
|
||||
site: "https://iliadobkin.com",
|
||||
@ -22,12 +21,25 @@ window.PORTFOLIO = {
|
||||
"@cloud","@a11y","@perf","@bdd","@ai","@infra","@leadership"
|
||||
],
|
||||
|
||||
/**
|
||||
* Open spec files — drive the editor tab strip above the main pane.
|
||||
* Each test below carries a `spec` matching one of these ids, so the
|
||||
* sidebar / report / source can filter by the active spec.
|
||||
*/
|
||||
specs: [
|
||||
{ id: "portfolio", file: "portfolio.spec.ts", describe: "Ilia Dobkin · portfolio" },
|
||||
{ id: "projects", file: "projects.spec.ts", describe: "Levkin · projects" },
|
||||
{ id: "skills", file: "skills.spec.ts", describe: "Ilia Dobkin · skills" },
|
||||
{ id: "playground", file: "playground.spec.ts", describe: "Ilia Dobkin · playground" },
|
||||
],
|
||||
|
||||
// The "test suite" — each entry maps to a section on the page
|
||||
suite: {
|
||||
name: "Ilia Dobkin · portfolio",
|
||||
tests: [
|
||||
{
|
||||
id: "about",
|
||||
spec: "portfolio",
|
||||
title: 'should introduce Ilia Dobkin',
|
||||
tags: ["@playwright","@leadership"],
|
||||
duration: 142,
|
||||
@ -40,6 +52,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "experience",
|
||||
spec: "portfolio",
|
||||
title: 'should list senior SDET experience',
|
||||
tags: ["@playwright","@api","@ci","@cloud","@leadership"],
|
||||
duration: 1280,
|
||||
@ -52,6 +65,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "skills",
|
||||
spec: "skills",
|
||||
title: 'should expose @-tagged skills',
|
||||
tags: ["@playwright","@cypress","@selenium","@api","@bdd","@ci","@docker","@terraform","@cloud","@a11y","@perf","@ai"],
|
||||
duration: 412,
|
||||
@ -63,6 +77,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "projects",
|
||||
spec: "projects",
|
||||
title: 'should showcase self-hosted projects',
|
||||
tags: ["@infra","@ai","@playwright","@docker"],
|
||||
duration: 680,
|
||||
@ -74,6 +89,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "stack",
|
||||
spec: "skills",
|
||||
title: 'should describe daily stack',
|
||||
tags: ["@docker","@terraform","@infra","@ai"],
|
||||
duration: 320,
|
||||
@ -85,6 +101,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "leadership",
|
||||
spec: "skills",
|
||||
title: 'should demonstrate quality leadership',
|
||||
tags: ["@leadership","@ci"],
|
||||
duration: 220,
|
||||
@ -96,6 +113,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "metrics",
|
||||
spec: "skills",
|
||||
title: 'should report quality KPIs',
|
||||
tags: ["@ci","@perf"],
|
||||
duration: 96,
|
||||
@ -107,6 +125,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
spec: "portfolio",
|
||||
title: 'should expose downloadable resume',
|
||||
tags: ["@playwright"],
|
||||
duration: 64,
|
||||
@ -118,6 +137,7 @@ window.PORTFOLIO = {
|
||||
},
|
||||
{
|
||||
id: "contact",
|
||||
spec: "portfolio",
|
||||
title: 'should accept inbound contact',
|
||||
tags: ["@api"],
|
||||
duration: 88,
|
||||
@ -127,6 +147,62 @@ window.PORTFOLIO = {
|
||||
],
|
||||
render: renderContact
|
||||
},
|
||||
{
|
||||
id: "perf-budget",
|
||||
spec: "portfolio",
|
||||
title: 'should meet performance budget',
|
||||
skip: true,
|
||||
skipReason: "Lighthouse CI not wired — pending infra (see IDEAS.md)",
|
||||
tags: ["@perf","@ci"],
|
||||
duration: 0,
|
||||
steps: [
|
||||
{ kind: "skip", title: 'run lighthouse --budget', dur: 0 },
|
||||
{ kind: "skip", title: 'assert LCP < 2.5s', dur: 0 },
|
||||
{ kind: "skip", title: 'assert CLS < 0.1', dur: 0 },
|
||||
],
|
||||
render: renderPerfBudget
|
||||
},
|
||||
{
|
||||
id: "response-time",
|
||||
spec: "portfolio",
|
||||
title: 'should match expected response time',
|
||||
fail: true,
|
||||
failMessage: `Error: expect(received).toBeLessThan(expected)
|
||||
|
||||
Expected: < 200
|
||||
Received: 347
|
||||
|
||||
at api.spec.ts:42:31
|
||||
→ GET /api/v1/repos/ilia/portfolio 347 ms
|
||||
────────────────────────────────────────
|
||||
Retry 1/2 … 312 ms ✗
|
||||
Retry 2/2 … 289 ms ✗
|
||||
|
||||
Threshold: 200 ms · Actual p95: 316 ms
|
||||
Hint: latency spike — possibly cold-start or DNS`,
|
||||
tags: ["@api","@perf"],
|
||||
duration: 0,
|
||||
steps: [
|
||||
{ kind: "ok", title: 'navigate to Gitea API endpoint', dur: 45 },
|
||||
{ kind: "ok", title: 'send GET /api/v1/repos/ilia/portfolio', dur: 62 },
|
||||
{ kind: "ok", title: 'assert status 200', dur: 8 },
|
||||
{ kind: "fail", title: 'expect(latency).toBeLessThan(200)', dur: 347 },
|
||||
],
|
||||
render: renderResponseTime
|
||||
},
|
||||
{
|
||||
id: "vibe-check",
|
||||
spec: "playground",
|
||||
title: 'should pass the vibe check',
|
||||
tags: ["@playwright"],
|
||||
duration: 110,
|
||||
steps: [
|
||||
{ kind: "info", title: 'await coffee.brew()', dur: 18 },
|
||||
{ kind: "ok", title: "expect(mood).toBe('☕')", dur: 32 },
|
||||
{ kind: "ok", title: 'expect(typing).toBeRhythmic', dur: 60 },
|
||||
],
|
||||
render: renderVibe
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
@ -234,7 +310,8 @@ window.PORTFOLIO = {
|
||||
|
||||
skills: [
|
||||
{ name: "Test automation: Playwright, Cypress, Selenium, SilkTest; UI, API, mobile, cross-browser; POM, BDD", level: 96, tags: ["@playwright","@cypress","@selenium","@bdd","@api"] },
|
||||
{ name: "Languages & frameworks: TypeScript, JavaScript, C#, .NET, Python, Java, Bash, Node.js", level: 92, tags: [] },
|
||||
{ name: "Languages: TypeScript, JavaScript, C#, Python, Java, Bash/Shell", level: 92, tags: [] },
|
||||
{ name: "Frameworks & runtimes: .NET (.NET Core, ASP.NET), Node.js, Spring Boot; markup: HTML/CSS", level: 90, tags: [] },
|
||||
{ name: "CI/CD & DevOps: GitHub Actions, GitLab, Bitbucket, Jenkins, Azure DevOps; Git, Terraform, Ansible, Docker", level: 92, tags: ["@ci","@docker","@terraform"] },
|
||||
{ name: "Cloud & infra: AWS, Azure, GCP; Linux administration, Proxmox, Caddy, TrueNAS", level: 84, tags: ["@cloud","@infra"] },
|
||||
{ name: "Observability & performance: Grafana, Prometheus, Sentry, DataDog, Artillery, k6, JMeter", level: 86, tags: ["@perf"] },
|
||||
@ -279,6 +356,33 @@ window.PORTFOLIO = {
|
||||
{ label: "Manual regression reduction", value: "≈ 50%" },
|
||||
{ label: "SpecFlow scenarios maintained", value: "3,500+" },
|
||||
{ label: "Years shipping software", value: "20+" }
|
||||
],
|
||||
|
||||
/**
|
||||
* Public repos on git.levkin.ca — descriptions from Gitea API when set,
|
||||
* otherwise first paragraph of README (see scripts/fetch-gitea-repos.mjs).
|
||||
* API lists all 19 repos on one page (explore UI may paginate).
|
||||
*/
|
||||
giteaRepos: [
|
||||
{ full_name: "ilia/ansible", name: "ansible", html_url: "https://git.levkin.ca/ilia/ansible", language: "Makefile", description: "Ansible automation for development machines, service hosts, and Proxmox-managed guests (LXC-first, with a path for KVM VMs)." },
|
||||
{ full_name: "ilia/AtAnyRate", name: "AtAnyRate", html_url: "https://git.levkin.ca/ilia/AtAnyRate", language: "Python", description: "Local Python application that identifies upcoming Toronto events likely to increase Airbnb demand, sends Telegram alerts, and optionally adjusts nightly prices via Playwright automation." },
|
||||
{ full_name: "ilia/atlas", name: "atlas", html_url: "https://git.levkin.ca/ilia/atlas", language: "Python", description: "Atlas is a local, privacy-focused home voice agent system — planning, architecture documentation, and kanban tickets for building the system." },
|
||||
{ full_name: "ilia/crkl", name: "crkl", html_url: "https://git.levkin.ca/ilia/crkl", language: "Kotlin", description: "Privacy-first Android AI assistant — circle or touch any element on-screen; on-device AI transcribes, summarizes, explains, or drafts responses." },
|
||||
{ full_name: "ilia/dotfiles", name: "dotfiles", html_url: "https://git.levkin.ca/ilia/dotfiles", language: "", description: "Dotfiles and shell configuration for dev machines." },
|
||||
{ full_name: "ilia/hilitehero", name: "hilitehero", html_url: "https://git.levkin.ca/ilia/hilitehero", language: "Python", description: "Python tool for extracting highlighted text from PDFs with precise ordering and hyphenation handling." },
|
||||
{ full_name: "ilia/invoice", name: "invoice", html_url: "https://git.levkin.ca/ilia/invoice", language: "JavaScript", description: "CLI for generating professional PDF invoices from JSON — interactive and non-interactive modes with preview-first workflow." },
|
||||
{ full_name: "ilia/Jobber", name: "Jobber", html_url: "https://git.levkin.ca/ilia/Jobber", language: "TypeScript", description: "Self-hosted job search orchestration — discover roles, score fit, draft resumes and cover letters, export PDFs, track email; you submit applications yourself." },
|
||||
{ full_name: "ilia/kanban", name: "kanban", html_url: "https://git.levkin.ca/ilia/kanban", language: "", description: "Kanban board project on self-hosted Gitea." },
|
||||
{ full_name: "ilia/linkedout", name: "linkedout", html_url: "https://git.levkin.ca/ilia/linkedout", language: "JavaScript", description: "Job market intelligence platform with integrated AI-powered insights — modular architecture for extensibility." },
|
||||
{ full_name: "ilia/llm_council", name: "llm_council", html_url: "https://git.levkin.ca/ilia/llm_council", language: "Python", description: "Local web UI like ChatGPT but sends each query to multiple LLMs — your \"LLM council\" votes with diverse models." },
|
||||
{ full_name: "ilia/mirror_match", name: "mirror_match", html_url: "https://git.levkin.ca/ilia/mirror_match", language: "TypeScript", description: "Photo guessing game — upload photos, others guess who is in the picture for points. Next.js, PostgreSQL, NextAuth." },
|
||||
{ full_name: "ilia/nanobot", name: "nanobot", html_url: "https://git.levkin.ca/ilia/nanobot", language: "Python", description: "Ultra-lightweight personal AI assistant (Python; published on PyPI as nanobot-ai)." },
|
||||
{ full_name: "ilia/onboarding", name: "onboarding", html_url: "https://git.levkin.ca/ilia/onboarding", language: "Shell", description: "Developer environment setup — automates 60+ apps and tools plus Git and SSH configuration." },
|
||||
{ full_name: "ilia/outreach", name: "outreach", html_url: "https://git.levkin.ca/ilia/outreach", language: "JavaScript", description: "Node.js email outreach for campaigns to law firms — templates, tracking, and tests." },
|
||||
{ full_name: "ilia/POTE", name: "POTE", html_url: "https://git.levkin.ca/ilia/POTE", language: "Python", description: "Research-oriented tool for tracking and analyzing public stock trades by government officials." },
|
||||
{ full_name: "ilia/profile", name: "profile", html_url: "https://git.levkin.ca/ilia/profile", language: "TypeScript", description: "Profile / personal site — TypeScript." },
|
||||
{ full_name: "ilia/punimtag", name: "punimtag", html_url: "https://git.levkin.ca/ilia/punimtag", language: "TypeScript", description: "Modern photo management and facial recognition system." },
|
||||
{ full_name: "ilia/resume", name: "resume", html_url: "https://git.levkin.ca/ilia/resume", language: "", description: "Résumé generator based on the best-resume-ever project." }
|
||||
]
|
||||
};
|
||||
|
||||
@ -311,8 +415,8 @@ function renderAbout(){
|
||||
function renderExperience(){
|
||||
return `<div class="block">${
|
||||
PORTFOLIO.experience.map((e,i)=>`
|
||||
<h4>${_esc(e.company)} <span style="color:var(--text-4);font-weight:400">— ${_esc(e.role)}</span></h4>
|
||||
<p style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-3);margin:2px 0 6px">${_esc(e.when)} · ${_esc(e.where)}</p>
|
||||
<h4>${_esc(e.company)}</h4>
|
||||
<p style="font-family:var(--font-mono);font-size:11.5px;color:var(--text-3);margin:2px 0 8px;line-height:1.45">${_esc(e.role)} · ${_esc(e.when)} · ${_esc(e.where)}</p>
|
||||
<ul>${e.bullets.map(b=>`<li>${_esc(b)}</li>`).join('')}</ul>
|
||||
`).join('')
|
||||
}</div>`;
|
||||
@ -342,7 +446,9 @@ function renderProjects(){
|
||||
<p>${_esc(p.desc)}</p>
|
||||
${_tags(p.tags)}
|
||||
</div>`).join('')
|
||||
}</div></div>`;
|
||||
}</div>
|
||||
<p class="projects-foot">Public code on <a href="https://git.levkin.ca/explore/repos" target="_blank" rel="noopener">git.levkin.ca</a> — open the <button type="button" class="tab-link" data-tab="network">Network</button> tab for an API-style request list.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStack(){
|
||||
@ -382,12 +488,12 @@ function renderMetrics(){
|
||||
|
||||
function renderResume(){
|
||||
return `<div class="block">
|
||||
<p>Full PDF resume — generated from this same source of truth.</p>
|
||||
<p>PDF resume — the same file used for applications.</p>
|
||||
<div class="cta-row">
|
||||
<button class="cta" id="dl-resume">⇩ download resume.pdf</button>
|
||||
<button class="cta cta--ghost" id="print-resume">print()</button>
|
||||
<button class="cta cta--ghost" id="print-resume">open in tab</button>
|
||||
</div>
|
||||
<p style="color:var(--text-4);font-size:11.5px;font-family:var(--font-mono);margin-top:10px">// resume renders inline using the print stylesheet — try ⌘P</p>
|
||||
<p style="color:var(--text-4);font-size:11.5px;font-family:var(--font-mono);margin-top:10px">// opens the PDF for viewing or printing from the browser</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@ -395,10 +501,67 @@ function renderContact(){
|
||||
const p = PORTFOLIO.person;
|
||||
return `<div class="block"><div class="contact-grid">
|
||||
<div class="contact-cell"><label>email</label><a href="mailto:${p.email}">${p.email}</a></div>
|
||||
<div class="contact-cell"><label>phone</label>${p.phone}</div>
|
||||
<div class="contact-cell"><label>linkedin</label><a href="${p.linkedin}" target="_blank" rel="noopener">in/idobkin</a></div>
|
||||
<div class="contact-cell"><label>gitea (self-hosted)</label><a href="${p.gitea}" target="_blank" rel="noopener">git.levkin.ca</a></div>
|
||||
<div class="contact-cell"><label>site</label><a href="${p.site}" target="_blank" rel="noopener">iliadobkin.com</a></div>
|
||||
<div class="contact-cell"><label>location</label>${p.location}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function renderPerfBudget(){
|
||||
return `<div class="block">
|
||||
<p style="color:var(--skip)">Lighthouse CI not wired — pending infra (see IDEAS.md)</p>
|
||||
<p>Once connected, this test will assert Core Web Vitals on every deploy: LCP<2.5s, CLS<0.1, TBT<200ms.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderResponseTime(){
|
||||
return `<div class="block">
|
||||
<p>The Gitea API endpoint <code>GET /api/v1/repos/ilia/portfolio</code> exceeded the <strong>200 ms</strong> latency budget three times in a row.</p>
|
||||
<h4>What happened</h4>
|
||||
<ul>
|
||||
<li>Initial request returned in <strong>347 ms</strong> — likely a cold-start on the VPS.</li>
|
||||
<li>Retry 1: <strong>312 ms</strong>, Retry 2: <strong>289 ms</strong> — trending down but still above threshold.</li>
|
||||
<li>The p95 across the three attempts was <strong>316 ms</strong>.</li>
|
||||
</ul>
|
||||
<h4>Possible fixes</h4>
|
||||
<ul>
|
||||
<li>Add a keep-alive cron to prevent cold-starts.</li>
|
||||
<li>Enable response caching on the reverse proxy.</li>
|
||||
<li>Raise the threshold to 350 ms if p50 is acceptable.</li>
|
||||
</ul>
|
||||
<div class="snippet"><div class="ln">1
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
6</div><div class="code"><span class="cm">// api.spec.ts:42</span>
|
||||
<span class="kw">const</span> res = <span class="kw">await</span> request.get(<span class="str">'/api/v1/repos/ilia/portfolio'</span>);
|
||||
<span class="kw">const</span> latency = res.headers[<span class="str">'x-response-time'</span>];
|
||||
expect(res.status()).toBe(<span class="num">200</span>);
|
||||
expect(Number(latency)).toBeLessThan(<span class="num">200</span>);
|
||||
<span class="cm">// ✗ Expected: < 200 · Received: 347</span></div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderVibe(){
|
||||
return `<div class="block">
|
||||
<p><code>playground.spec.ts</code> is reserved for interactive demos and small experiments — the things that don't quite belong in the resume but make this site fun to poke at.</p>
|
||||
<h4>On the runway</h4>
|
||||
<ul>
|
||||
<li><strong>Real Playwright tests of this site</strong> — meta, self-referential, satisfying. The runner that <em>looks</em> like a Playwright report becomes one that <em>is</em> verified by Playwright.</li>
|
||||
<li><strong>Recording mode</strong> — MediaRecorder API captures a 20s Run-All clip you can drop into Slack.</li>
|
||||
<li><strong>Narrative replay</strong> — click a tag, watch the runner play that storyline end-to-end.</li>
|
||||
<li><strong>Konami easter egg</strong> — because every test runner deserves one.</li>
|
||||
</ul>
|
||||
<div class="snippet"><div class="ln">1
|
||||
2
|
||||
3
|
||||
4
|
||||
5</div><div class="code"><span class="cm">// playground.spec.ts</span>
|
||||
<span class="kw">test</span>(<span class="str">'should pass the vibe check'</span>, <span class="kw">async</span> ({ page }) => {
|
||||
<span class="kw">const</span> coffee = <span class="kw">await</span> kitchen.brew();
|
||||
<span class="kw">await</span> expect(coffee.temperature).toBeWithinRange(<span class="num">63</span>, <span class="num">68</span>);
|
||||
});</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
1119
package-lock.json
generated
Normal file
1119
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "portfolio-tests",
|
||||
"private": true,
|
||||
"description": "Playwright tests for iliadobkin.com — the portfolio that looks like a Playwright report, tested by actual Playwright.",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:ui": "playwright test --ui",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"serve": "^14.2.6"
|
||||
}
|
||||
}
|
||||
34
playwright.config.ts
Normal file
34
playwright.config.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const PORT = 3173;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 8 : undefined,
|
||||
reporter: "html",
|
||||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.BASE_URL || `http://localhost:${PORT}`,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
headless: true,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
// { name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
// { name: "webkit", use: { ...devices["Desktop Safari"] } },
|
||||
],
|
||||
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: `npx serve -l ${PORT} --no-clipboard`,
|
||||
port: PORT,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
95
scripts/fetch-gitea-repos.mjs
Normal file
95
scripts/fetch-gitea-repos.mjs
Normal file
@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Refresh PORTFOLIO.giteaRepos descriptions from git.levkin.ca:
|
||||
* uses GET /api/v1/repos/search (all public repos), then README.md per repo
|
||||
* when description is empty.
|
||||
*
|
||||
* Run from repo root: node scripts/fetch-gitea-repos.mjs
|
||||
* Paste the printed `giteaRepos: [...]` block into js/data.js (no npm deps).
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
|
||||
function get(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { 'User-Agent': 'portfolio-fetch-gitea/1' } }, (res) => {
|
||||
let d = '';
|
||||
res.on('data', c => { d += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: d }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function readmeSnippet(owner, repo, branch) {
|
||||
const url = `https://git.levkin.ca/api/v1/repos/${owner}/${repo}/contents/README.md?ref=${branch}`;
|
||||
const { status, body } = await get(url);
|
||||
if (status !== 200) return null;
|
||||
let j;
|
||||
try { j = JSON.parse(body); } catch { return null; }
|
||||
if (!j.content) return null;
|
||||
const text = Buffer.from(j.content, 'base64').toString('utf8');
|
||||
const lines = text.split(/\r?\n/);
|
||||
let i = 0;
|
||||
while (i < lines.length && (/^#+\s/.test(lines[i]) || lines[i].trim() === '')) i++;
|
||||
const para = [];
|
||||
for (; i < lines.length; i++) {
|
||||
const L = lines[i].trim();
|
||||
if (L === '') break;
|
||||
if (/^#+\s/.test(lines[i])) break;
|
||||
para.push(L.replace(/^[-*]\s+/, ''));
|
||||
}
|
||||
let s = para.join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (!s) {
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (t && !t.startsWith('#') && !t.startsWith('```')) { s = t; break; }
|
||||
}
|
||||
}
|
||||
if (s.includes('<')) {
|
||||
s = s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
if (s.length > 280) s = s.slice(0, 277) + '…';
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function jsString(s) {
|
||||
return JSON.stringify(s);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { body } = await get('https://git.levkin.ca/api/v1/repos/search?limit=100&page=1');
|
||||
const j = JSON.parse(body);
|
||||
const repos = j.data.sort((a, b) => a.full_name.localeCompare(b.full_name));
|
||||
const rows = [];
|
||||
|
||||
for (const r of repos) {
|
||||
const [owner, name] = r.full_name.split('/');
|
||||
let desc = (r.description || '').trim();
|
||||
if (!desc) {
|
||||
desc = await readmeSnippet(owner, name, r.default_branch || 'main');
|
||||
if (!desc && r.default_branch !== 'master') {
|
||||
desc = await readmeSnippet(owner, name, 'master');
|
||||
}
|
||||
}
|
||||
if (!desc) {
|
||||
desc = r.language ? `${r.language} repository.` : 'Self-hosted repo on git.levkin.ca.';
|
||||
}
|
||||
|
||||
rows.push({
|
||||
full_name: r.full_name,
|
||||
name,
|
||||
html_url: r.html_url,
|
||||
language: r.language || '',
|
||||
description: desc,
|
||||
});
|
||||
await new Promise(res => setTimeout(res, 100));
|
||||
}
|
||||
|
||||
console.log('// --- Replace PORTFOLIO.giteaRepos in js/data.js with: ---\n');
|
||||
console.log(' giteaRepos: [');
|
||||
for (const row of rows) {
|
||||
console.log(` { full_name: ${jsString(row.full_name)}, name: ${jsString(row.name)}, html_url: ${jsString(row.html_url)}, language: ${jsString(row.language)}, description: ${jsString(row.description)} },`);
|
||||
}
|
||||
console.log(' ],');
|
||||
console.error(`\n// Done — ${rows.length} repos (API lists all public repos on page 1).`);
|
||||
})();
|
||||
443
tests/portfolio.spec.ts
Normal file
443
tests/portfolio.spec.ts
Normal file
@ -0,0 +1,443 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Wait for idle UI state (no spinners, status says "idle" or "all passed"). */
|
||||
async function waitForIdle(page: Page) {
|
||||
await expect(page.locator("#status-text")).not.toHaveText("running", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Click "Run all" and wait for every runnable test to finish. */
|
||||
async function runAllAndWait(page: Page) {
|
||||
await page.click("#run-all");
|
||||
await expect(page.locator("#status-text")).toHaveText("all passed", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Run a single test by clicking its ▶ button in the sidebar tree. */
|
||||
async function runSingleTest(page: Page, testId: string) {
|
||||
await page.click(`.test[data-id="${testId}"] .test__run`);
|
||||
await expect(page.locator(`#result-${testId}`)).toHaveClass(/is-open/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smoke — page loads and renders the shell
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("smoke", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("page has correct title", async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/Ilia Dobkin/);
|
||||
});
|
||||
|
||||
test("hero section renders name and role", async ({ page }) => {
|
||||
await expect(page.locator(".hero__title")).toHaveText("Ilia Dobkin");
|
||||
await expect(page.locator(".hero__sub")).toContainText("Senior SDET");
|
||||
});
|
||||
|
||||
test("topbar contains Run all, Stop, Reset, and Theme toggle", async ({
|
||||
page,
|
||||
}) => {
|
||||
await expect(page.locator("#run-all")).toBeVisible();
|
||||
await expect(page.locator("#stop-all")).toBeVisible();
|
||||
await expect(page.locator("#reset-all")).toBeVisible();
|
||||
await expect(page.locator("#theme-toggle")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar test explorer lists tests", async ({ page }) => {
|
||||
const tests = page.locator("#tree .test");
|
||||
await expect(tests.first()).toBeVisible();
|
||||
expect(await tests.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("status bar shows Playwright version", async ({ page }) => {
|
||||
await expect(page.locator(".statusbar")).toContainText("Playwright");
|
||||
});
|
||||
|
||||
test("five tabs exist: Report, Trace, Source, Network, Console", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const label of ["Report", "Trace", "Source", "Network", "Console"]) {
|
||||
await expect(page.locator(`.tab[data-tab]`, { hasText: label })).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run engine — Run All, individual runs, reset
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("run engine", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForIdle(page);
|
||||
});
|
||||
|
||||
test("Run All executes and all tests pass", async ({ page }) => {
|
||||
await runAllAndWait(page);
|
||||
|
||||
const passCount = page.locator("#cnt-pass");
|
||||
expect(Number(await passCount.textContent())).toBeGreaterThan(0);
|
||||
await expect(page.locator("#cnt-fail")).toHaveText("0");
|
||||
});
|
||||
|
||||
test("clicking ▶ on a single test runs only that test", async ({
|
||||
page,
|
||||
}) => {
|
||||
await runSingleTest(page, "about");
|
||||
|
||||
await expect(
|
||||
page.locator('.test[data-id="about"]')
|
||||
).toHaveClass(/is-passed/);
|
||||
|
||||
const passCount = Number(await page.locator("#cnt-pass").textContent());
|
||||
expect(passCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("Reset clears all results back to idle", async ({ page }) => {
|
||||
await runAllAndWait(page);
|
||||
await page.click("#reset-all");
|
||||
|
||||
await expect(page.locator("#status-text")).toHaveText("idle");
|
||||
await expect(page.locator("#cnt-pass")).toHaveText("0");
|
||||
});
|
||||
|
||||
test("skipped test shows skip icon and badge", async ({ page }) => {
|
||||
const skippedRow = page.locator('.test[data-id="perf-budget"]');
|
||||
await expect(skippedRow).toHaveClass(/is-skipped/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("theme", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("starts in dark mode by default", async ({ page }) => {
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
|
||||
test("theme toggle cycles through dark → light → hc → dark", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.click("#theme-toggle");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
|
||||
await page.click("#theme-toggle");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "hc");
|
||||
|
||||
await page.click("#theme-toggle");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("tabs", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("clicking each tab reveals corresponding pane", async ({ page }) => {
|
||||
const tabs = ["report", "trace", "source", "network", "console"];
|
||||
|
||||
for (const tabId of tabs) {
|
||||
await page.click(`.tab[data-tab="${tabId}"]`);
|
||||
await expect(page.locator(`#pane-${tabId}`)).toHaveClass(/is-active/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Source tab renders spec-like code", async ({ page }) => {
|
||||
await page.click('.tab[data-tab="source"]');
|
||||
await expect(page.locator("#source")).toContainText("import");
|
||||
await expect(page.locator("#source")).toContainText("test.describe");
|
||||
});
|
||||
|
||||
test("Trace tab renders career timeline", async ({ page }) => {
|
||||
await page.click('.tab[data-tab="trace"]');
|
||||
const rows = page.locator(".trace-row");
|
||||
expect(await rows.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Network tab renders Gitea repo rows", async ({ page }) => {
|
||||
await page.click('.tab[data-tab="network"]');
|
||||
const entries = page.locator(".network-entry");
|
||||
await expect(entries.first()).toBeVisible();
|
||||
expect(await entries.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Console tab logs events after a test run", async ({ page }) => {
|
||||
await runSingleTest(page, "about");
|
||||
await page.click('.tab[data-tab="console"]');
|
||||
const lines = page.locator(".console__line");
|
||||
expect(await lines.count()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Editor strip — switching spec files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("editor strip", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("editor tabs exist for all spec files", async ({ page }) => {
|
||||
for (const file of [
|
||||
"portfolio.spec.ts",
|
||||
"projects.spec.ts",
|
||||
"skills.spec.ts",
|
||||
"playground.spec.ts",
|
||||
]) {
|
||||
await expect(
|
||||
page.locator(".espec", { hasText: file })
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking a spec tab switches the active spec", async ({ page }) => {
|
||||
await page.click('.espec[data-spec="projects"]');
|
||||
await expect(
|
||||
page.locator('.espec[data-spec="projects"]')
|
||||
).toHaveClass(/is-active/);
|
||||
|
||||
const crumb = page.locator(".crumb strong");
|
||||
await expect(crumb).toHaveText("projects.spec.ts");
|
||||
});
|
||||
|
||||
test("switching spec shows Report pane with updated hero", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.click('.espec[data-spec="projects"]');
|
||||
|
||||
await expect(page.locator("#pane-report")).toHaveClass(/is-active/);
|
||||
await expect(page.locator(".hero__title")).toHaveText("Projects");
|
||||
});
|
||||
|
||||
test("switching spec updates the Source tab", async ({ page }) => {
|
||||
await page.click('.espec[data-spec="skills"]');
|
||||
await page.click('.tab[data-tab="source"]');
|
||||
await expect(page.locator("#source")).toContainText("skills");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar — grep filter and tag chips
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("filtering", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("typing in grep input filters visible tests", async ({ page }) => {
|
||||
const allBefore = await page.locator("#tree .test").count();
|
||||
|
||||
await page.fill("#grep", "resume");
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const visible = page.locator('#tree .test:not([style*="display: none"])');
|
||||
const visibleCount = await visible.count();
|
||||
expect(visibleCount).toBeLessThan(allBefore);
|
||||
expect(visibleCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("clicking a tag chip activates it and filters", async ({ page }) => {
|
||||
const tag = page.locator('#tag-bar .tag[data-tag="@api"]');
|
||||
await tag.click();
|
||||
await expect(tag).toHaveClass(/is-active/);
|
||||
|
||||
await expect(page.locator("#grep")).toHaveValue(/@api/);
|
||||
});
|
||||
|
||||
test("clear button removes all tag filters", async ({ page }) => {
|
||||
await page.click('#tag-bar .tag[data-tag="@api"]');
|
||||
await page.click("#tag-bar [data-clear]");
|
||||
await expect(page.locator("#grep")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("keyboard shortcuts", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForIdle(page);
|
||||
});
|
||||
|
||||
test("pressing ? opens keyboard shortcuts overlay", async ({ page }) => {
|
||||
await page.keyboard.press("?");
|
||||
await expect(page.locator("#kshelp")).not.toHaveAttribute("hidden", "");
|
||||
});
|
||||
|
||||
test("pressing Escape closes the shortcuts overlay", async ({ page }) => {
|
||||
await page.keyboard.press("?");
|
||||
await expect(page.locator("#kshelp")).not.toHaveAttribute("hidden", "");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#kshelp")).toHaveAttribute("hidden", "");
|
||||
});
|
||||
|
||||
test("pressing T toggles theme", async ({ page }) => {
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.keyboard.press("t");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
|
||||
});
|
||||
|
||||
test("pressing / focuses the grep input", async ({ page }) => {
|
||||
await page.keyboard.press("/");
|
||||
await expect(page.locator("#grep")).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overflow menu (workers / headed)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("overflow menu", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("clicking ⋯ opens the overflow menu", async ({ page }) => {
|
||||
await page.click("#overflow-btn");
|
||||
await expect(page.locator("#overflow-menu")).not.toHaveAttribute(
|
||||
"hidden",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("workers dropdown is accessible", async ({ page }) => {
|
||||
await page.click("#overflow-btn");
|
||||
await expect(page.locator("#workers")).toBeVisible();
|
||||
await page.selectOption("#workers", "4");
|
||||
await expect(page.locator("#workers")).toHaveValue("4");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Network tab — expand/collapse repo detail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("network detail", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.click('.tab[data-tab="network"]');
|
||||
});
|
||||
|
||||
test("clicking a network row expands its detail panel", async ({ page }) => {
|
||||
const firstEntry = page.locator(".network-entry").first();
|
||||
await firstEntry.locator(".network-row").click();
|
||||
await expect(firstEntry).toHaveClass(/is-open/);
|
||||
await expect(firstEntry.locator(".network-detail")).not.toHaveAttribute(
|
||||
"hidden",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("expanded detail shows JSON body and repo link", async ({ page }) => {
|
||||
const firstEntry = page.locator(".network-entry").first();
|
||||
await firstEntry.locator(".network-row").click();
|
||||
|
||||
await expect(
|
||||
firstEntry.locator(".network-detail__body")
|
||||
).toContainText("full_name");
|
||||
await expect(
|
||||
firstEntry.locator(".network-detail__link")
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility basics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("accessibility", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("all interactive buttons have accessible names", async ({ page }) => {
|
||||
const buttons = page.locator(
|
||||
"button:visible:not(.tag):not(.espec):not(.tab):not(.cmdpal__item)"
|
||||
);
|
||||
const count = await buttons.count();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = buttons.nth(i);
|
||||
const name = await btn.getAttribute("aria-label");
|
||||
const title = await btn.getAttribute("title");
|
||||
const text = (await btn.textContent())?.trim();
|
||||
const hasName = (name && name.length > 0) ||
|
||||
(title && title.length > 0) ||
|
||||
(text && text.length > 0);
|
||||
expect(hasName, `Button ${i} should have an accessible name`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("landmark roles are present", async ({ page }) => {
|
||||
await expect(page.locator('[role="banner"]')).toBeVisible();
|
||||
await expect(page.locator('[role="contentinfo"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Responsive — mobile viewport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("responsive", () => {
|
||||
test.use({ viewport: { width: 375, height: 812 } });
|
||||
|
||||
test("sidebar is hidden by default on mobile", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const sidebar = page.locator("#sidebar");
|
||||
await expect(sidebar).not.toHaveClass(/is-open/);
|
||||
});
|
||||
|
||||
test("menu button opens sidebar drawer on mobile", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.click("#menu-btn");
|
||||
await expect(page.locator("#sidebar")).toHaveClass(/is-open/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// End-to-end: full run lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("full lifecycle", () => {
|
||||
test("run all → verify results → reset → verify idle", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForIdle(page);
|
||||
|
||||
await runAllAndWait(page);
|
||||
|
||||
const passed = Number(await page.locator("#cnt-pass").textContent());
|
||||
expect(passed).toBeGreaterThan(0);
|
||||
await expect(page.locator("#cnt-fail")).toHaveText("0");
|
||||
|
||||
await expect(page.locator("#summary-stripe")).toContainText("passed");
|
||||
|
||||
await page.click("#reset-all");
|
||||
await expect(page.locator("#status-text")).toHaveText("idle");
|
||||
await expect(page.locator("#cnt-pass")).toHaveText("0");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user