diff --git a/.gitignore b/.gitignore index 24ff6d0..c1909b2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/IDEAS.md b/IDEAS.md index 586e03e..8ba6a96 100644 --- a/IDEAS.md +++ b/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?" diff --git a/README.md b/README.md index 49db163..34b7e80 100644 --- a/README.md +++ b/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()` 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: ''` 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)` | diff --git a/assets/ilia-dobkin-resume.pdf b/assets/ilia-dobkin-resume.pdf new file mode 100644 index 0000000..43ae937 Binary files /dev/null and b/assets/ilia-dobkin-resume.pdf differ diff --git a/css/app.css b/css/app.css index 1eaf589..19affa0 100644 --- a/css/app.css +++ b/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; } +} diff --git a/css/base.css b/css/base.css index a080208..25a81fb 100644 --- a/css/base.css +++ b/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 { diff --git a/index.html b/index.html index ae87c16..b24065c 100644 --- a/index.html +++ b/index.html @@ -9,8 +9,8 @@ - - + + @@ -32,18 +32,6 @@ / tests / portfolio.spec.ts -
-
- - idle - - 0 - 0 - 0 - -
-
-
- - +
@@ -85,19 +76,57 @@
+ +
+
+
+
+ + idle + + 0 + 0 + 0 + +
+
+ + +
+
+
+
+
- portfolio.spec.ts · 9 tests
@@ -108,6 +137,9 @@

Senior SDET · Toronto, ON · test.describe("portfolio")

Click the green next to any test to run it — or press Run all above.

+ +
@@ -127,6 +159,17 @@
+ +
+
+
+
Network · git.levkin.ca
+
200application/json
+
+
+
+
+
@@ -146,7 +189,20 @@ Playwright 1.49 - - + + + + + diff --git a/js/app.js b/js/app.js index f619f66..bbc0c1f 100644 --- a/js/app.js +++ b/js/app.js @@ -4,17 +4,38 @@ const $$ = (sel,root=document)=>Array.from(root.querySelectorAll(sel)); const data = window.PORTFOLIO; const tests = data.suite.tests; + const specs = Array.isArray(data.specs) && data.specs.length + ? data.specs + : [{ id: 'portfolio', file: 'portfolio.spec.ts', describe: data.suite.name }]; + /** Canonical resume file (sync with repo `assets/ilia-dobkin-resume.pdf`). */ + const RESUME_PDF = 'assets/ilia-dobkin-resume.pdf'; - // Test state: idle | running | passed - const state = Object.fromEntries(tests.map(t=>[t.id, { status:'idle', runtime:0 }])); + // Test state: idle | running | passed | skipped + const state = Object.fromEntries(tests.map(t=>[t.id, { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }])); let isRunningAll = false; let activeTimers = []; const headed = () => $('#headed').checked; + const getWorkers = () => Math.max(1, parseInt($('#workers').value, 10) || 1); + + // Active spec controls which tests are visible in tree / results / source. + // Restored from cookie on init; falls back to the first spec. + function readSpecCookie(){ + const m = document.cookie.match(/(?:^|;\s*)spec=([a-z0-9_-]+)/i); + return m ? m[1] : null; + } + function writeSpecCookie(v){ + try { document.cookie = `spec=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch(_) {} + } + function getSpec(id){ return specs.find(s => s.id === id) || specs[0]; } + function specCountFor(id){ return tests.filter(t => t.spec === id).length; } + let activeSpec = (specs.find(s => s.id === readSpecCookie()) || specs[0]).id; // ============ ICONS ============ const ICONS = { play: '', check: '', + skip: '', + fail: '', dot: '', spin: '', chev: '', @@ -23,11 +44,15 @@ function statusIcon(s){ if (s==='running') return ICONS.spin; if (s==='passed') return ICONS.check; + if (s==='failed') return ICONS.fail; + if (s==='skipped') return ICONS.skip; return ICONS.dot; } function statusClass(s){ if (s==='running') return 'icon--run'; if (s==='passed') return 'icon--pass'; + if (s==='failed') return 'icon--fail'; + if (s==='skipped') return 'icon--skip'; return 'icon--idle'; } @@ -37,30 +62,58 @@ } // ============ SIDEBAR (tree) ============ + // Renders a Test Explorer with ALL specs visible (one collapsible + // describe block per spec). The editor tab strip controls which spec + // is "open" in the right pane; clicking a test row in any suite + // auto-switches the active tab to that test's spec. function renderTree(){ const wrap = $('#tree'); - wrap.innerHTML = ` -
+ wrap.innerHTML = specs.map(s => { + const specTests = tests.filter(t => t.spec === s.id); + const cls = s.id === activeSpec ? 'is-active' : ''; + return ` +
${ICONS.chev} - describe('${data.suite.name}') + describe('${s.describe}') + ${specTests.length}
- ${tests.map(renderTreeTest).join('')} + ${specTests.map(renderTreeTest).join('')}
`; + }).join(''); wrap.addEventListener('click', e=>{ const head = e.target.closest('.suite__head'); if (head) { head.parentElement.classList.toggle('collapsed'); return; } const runBtn = e.target.closest('.test__run'); - if (runBtn) { e.stopPropagation(); runTest(runBtn.dataset.id); return; } + if (runBtn) { + e.stopPropagation(); + const id = runBtn.dataset.id; + const t = tests.find(x => x.id === id); + if (t && t.spec !== activeSpec) activateSpec(t.spec); + runTest(id); + return; + } const row = e.target.closest('.test'); - if (row) { selectTest(row.dataset.id, true); } + if (row) { + const id = row.dataset.id; + const t = tests.find(x => x.id === id); + if (t && t.spec !== activeSpec) activateSpec(t.spec); + selectTest(id, true); + } }); } function renderTreeTest(t){ const s = state[t.id]; + const cls = [ + 'test', + s.status==='running' ? 'is-running' : '', + s.status==='passed' ? 'is-passed' : '', + s.status==='failed' ? 'is-failed' : '', + s.status==='skipped' ? 'is-skipped' : '', + ].filter(Boolean).join(' '); return ` -
+
${statusIcon(s.status)} ${titleHTML(t)} @@ -75,6 +128,8 @@ if (!row) return; row.classList.toggle('is-running', s.status==='running'); row.classList.toggle('is-passed', s.status==='passed'); + row.classList.toggle('is-failed', s.status==='failed'); + row.classList.toggle('is-skipped', s.status==='skipped'); const iconEl = row.querySelector('.test__icon'); iconEl.className = `test__icon ${statusClass(s.status)}`; iconEl.innerHTML = statusIcon(s.status); @@ -82,44 +137,166 @@ } // ============ TAG BAR ============ + // Show the first VISIBLE_TAGS by default; the rest hide behind a "+N more" + // chip that expands them inline on click. Keeps the sidebar visual mass + // small until a visitor needs the full registry. + const VISIBLE_TAGS = 6; let activeTags = new Set(); function renderTagBar(){ const bar = $('#tag-bar'); - bar.innerHTML = data.tags.map(t=>`${t}`).join(''); + const all = Array.isArray(data.tags) ? data.tags : []; + const hidden = Math.max(0, all.length - VISIBLE_TAGS); + const tagHTML = all.map((t, i) => + `${t}` + ).join(''); + const moreHTML = hidden > 0 + ? `` + : ''; + const clearHTML = ``; + bar.innerHTML = tagHTML + moreHTML + clearHTML; bar.addEventListener('click', e=>{ + const clr = e.target.closest('[data-clear]'); + if (clr) { clearTagFilter(); return; } + const more = e.target.closest('[data-more]'); + if (more) { + bar.classList.add('is-expanded'); + more.setAttribute('aria-expanded', 'true'); + return; + } const el = e.target.closest('.tag'); if (!el) return; const tag = el.dataset.tag; if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag); - bar.querySelectorAll('.tag').forEach(t=>{ - t.classList.toggle('is-active', activeTags.has(t.dataset.tag)); - }); - // Reflect into grep input - $('#grep').value = Array.from(activeTags).join(' '); - applyFilter(); + syncTagBarUI(); }); } + function syncTagBarUI(){ + const bar = $('#tag-bar'); + bar.querySelectorAll('.tag').forEach(t=>{ + t.classList.toggle('is-active', activeTags.has(t.dataset.tag)); + }); + bar.classList.toggle('has-active', activeTags.size > 0); + $('#grep').value = Array.from(activeTags).join(' '); + applyFilter(); + } + + function clearTagFilter(){ + activeTags.clear(); + syncTagBarUI(); + } + function applyFilter(){ const grep = $('#grep').value.trim().toLowerCase(); const wantedTags = grep.split(/\s+/).filter(s=>s.startsWith('@')); const text = grep.replace(/@\S+/g,'').trim(); - $$('.test').forEach(row=>{ - const id = row.dataset.id; - const t = tests.find(x=>x.id===id); - const tagsOK = wantedTags.length===0 || wantedTags.every(tg => t.tags.includes(tg)); - const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text); - row.style.display = (tagsOK && textOK) ? '' : 'none'; + const hasFilter = wantedTags.length > 0 || text.length > 0; + + // Sidebar tree — filter per-suite so we can hide entire describe blocks + // when none of their tests match (prevents the awkward "open caret but no + // rows" look that reads as a broken collapsed state). + $$('[data-suite]').forEach(suite => { + let anyVisible = false; + suite.querySelectorAll('.test').forEach(row => { + const id = row.dataset.id; + const t = tests.find(x => x.id === id); + if (!t) { row.style.display = 'none'; return; } + const tagsOK = wantedTags.length === 0 || wantedTags.every(tg => t.tags.includes(tg)); + const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text); + const visible = tagsOK && textOK; + row.style.display = visible ? '' : 'none'; + if (visible) anyVisible = true; + }); + // No filter → always show all suites. With a filter → hide suites that + // have zero matching tests; auto-expand suites that do match. + suite.style.display = (!hasFilter || anyVisible) ? '' : 'none'; + if (hasFilter && anyVisible) suite.classList.remove('collapsed'); + }); + + // Right-pane results — only active spec is visible. The editor tab is + // the "open file"; we render its body, not the rest of the project. + $$('.result').forEach(art => { + const id = art.dataset.id; + const t = tests.find(x => x.id === id); + art.style.display = (t && t.spec === activeSpec) ? '' : 'none'; }); } // ============ MAIN PANE RESULTS ============ + // Returns the id of the first idle test for the active spec — the one we + // render as a "pending preview" so the page never looks empty before a run. + function firstIdleId(){ + const t = tests.find(x => x.spec === activeSpec && state[x.id].status === 'idle' && !x.skip && !x.fail); + return t ? t.id : null; + } + + // Pre-render the body for the pending-preview test: steps + the section's + // own render(). Wrapped with an overlay so it reads as "preview, not result". + function renderPendingBody(t){ + const stepsHTML = t.steps.map(st => ` +
+ ${st.kind==='info'?ICONS.dot:ICONS.check} + ${st.title} + ${formatMs(st.dur)} +
`).join(''); + const overlay = ``; + return `${overlay}
${stepsHTML}
${t.render()}`; + } + + function renderSkippedBody(t){ + const stepsHTML = t.steps.map(st => ` +
+ ${ICONS.skip} + ${st.title} + +
`).join(''); + const reason = t.skipReason || 'test.skip()'; + return `
${stepsHTML}
${t.render ? t.render() : `

${reason}

`}`; + } + + function renderFailedBody(t){ + const stepsHTML = t.steps.map(st => { + const isFail = st.kind === 'fail'; + const icon = isFail ? ICONS.fail : ICONS.check; + const cls = isFail ? 'is-fail' : 'is-pass'; + return ` +
+ ${icon} + ${st.title} + ${st.dur ? formatMs(st.dur) : '—'} +
`; + }).join(''); + const msg = t.failMessage || 'Assertion error'; + return `
${stepsHTML}
${t.render ? t.render() : `
${msg}
`}`; + } + function renderResults(){ const wrap = $('#results'); + const pendingId = firstIdleId(); wrap.innerHTML = tests.map(t=>{ const s = state[t.id]; + const isPending = t.id === pendingId; + const isSkip = s.status === 'skipped'; + const isFail = s.status === 'failed'; + + let bodyHTML = ''; + let cls = 'result'; + let dataAttr = ''; + + if (isFail) { + cls = 'result is-open is-failed'; + bodyHTML = renderFailedBody(t); + } else if (isSkip) { + cls = 'result is-open is-skipped'; + bodyHTML = renderSkippedBody(t); + } else if (isPending) { + cls = 'result is-open is-pending'; + dataAttr = ' data-pending="1"'; + bodyHTML = renderPendingBody(t); + } + return ` -
+
${ICONS.chev} ${statusIcon(s.status)} @@ -128,11 +305,13 @@
-
+
${bodyHTML}
`; }).join(''); wrap.addEventListener('click', e=>{ + const pendingBtn = e.target.closest('[data-pending-run]'); + if (pendingBtn) { e.stopPropagation(); runTest(pendingBtn.dataset.pendingRun); return; } const rerun = e.target.closest('.result__rerun'); if (rerun) { e.stopPropagation(); runTest(rerun.dataset.id); return; } const head = e.target.closest('.result__head'); @@ -165,12 +344,13 @@
`).join(''); body.innerHTML = `
${stepsHTML}
${t.render()}`; art.dataset.populated = '1'; + art.classList.remove('is-pending'); // un-fade — real result is in. art.classList.add('is-open'); // Animate skill bars if present $$('.skill__fill', body).forEach(el => requestAnimationFrame(()=> el.style.width = el.dataset.pct + '%')); // Hook resume buttons const dl = $('#dl-resume', body); if (dl) dl.addEventListener('click', downloadResume); - const pr = $('#print-resume', body); if (pr) pr.addEventListener('click', ()=>window.print()); + const pr = $('#print-resume', body); if (pr) pr.addEventListener('click', ()=> window.open(RESUME_PDF, '_blank', 'noopener,noreferrer')); } } @@ -188,6 +368,20 @@ if (!t) return; const s = state[id]; if (s.status === 'running') return; + if (t.skip) { consoleLine('warn', `⊘ test('${t.title}') skipped`); return; } + if (t.fail) { consoleLine('err', `✗ test('${t.title}') failed — ${t.failMessage ? t.failMessage.split('\n')[0] : 'assertion error'}`); return; } + // If this row was sitting in the pending-preview state, lift the overlay + // but keep the faded preview body visible through the run — that way the + // report never blanks mid-animation. The "is-pending" class stays on + // (so the body stays greyed) until refreshResultRow swaps in the real + // result on completion. + const art = $(`#result-${id}`); + if (art && art.dataset.pending) { + art.removeAttribute('data-pending'); + const overlay = art.querySelector('.result__pending-overlay'); + if (overlay) overlay.remove(); + art.dataset.populated = ''; + } // reset s.status = 'running'; s.runtime = 0; refreshTreeRow(id); refreshResultRow(id); updateStatusbar(); @@ -206,7 +400,7 @@ bar.style.width = '100%'; setTimeout(()=>{ bar.style.transition='opacity .4s'; bar.style.opacity='0'; }, 200); // Clear populated flag so it re-renders fresh on re-run - const art = $(`#result-${id}`); if (art) art.dataset.populated = ''; + if (art) art.dataset.populated = ''; refreshTreeRow(id); refreshResultRow(id); updateStatusbar(); consoleLine('ok', `✓ test('${t.title}') passed in ${formatMs(elapsed)}`); } @@ -216,38 +410,77 @@ isRunningAll = true; $('#run-all').disabled = true; $('#stop-all').disabled = false; - consoleLine('info', `▶ playwright test (workers=1)`); - for (const t of tests) { + + // Build the queue from the active spec, in suite order. Skip tests + // explicitly marked skip, and respect grep/tag filters via row display. + const queue = tests.filter(t => { + if (t.spec !== activeSpec) return false; + if (t.skip || t.fail) return false; const row = $(`.test[data-id="${t.id}"]`); - if (row && row.style.display === 'none') continue; // respect filter - // reset to idle if previously passed - state[t.id].status = 'idle'; state[t.id].runtime = 0; - const art = $(`#result-${t.id}`); if (art) { art.dataset.populated=''; const b=$(`#bar-${t.id}`); if(b){b.style.width='0%';b.style.opacity='1';b.style.transition='';}} - refreshTreeRow(t.id); refreshResultRow(t.id); - await runTest(t.id); - if (!isRunningAll) break; + return !row || row.style.display !== 'none'; + }); + + // Reset every queued test up front so all rows are visibly idle before + // workers fan out — otherwise the first row would briefly look special. + for (const t of queue) { + state[t.id].status = 'idle'; + state[t.id].runtime = 0; + const art = $(`#result-${t.id}`); + if (art) { + art.dataset.populated = ''; + const b = $(`#bar-${t.id}`); + if (b) { b.style.width = '0%'; b.style.opacity = '1'; b.style.transition = ''; } + } + refreshTreeRow(t.id); + refreshResultRow(t.id); } + updateStatusbar(); + + const N = Math.min(getWorkers(), queue.length || 1); + const sf = getSpec(activeSpec).file; + consoleLine('info', `▶ playwright test ${sf} (workers=${N})`); + + let cursor = 0; + const startedAt = performance.now(); + async function worker(){ + while (isRunningAll) { + const next = queue[cursor++]; + if (!next) return; + await runTest(next.id); + } + } + await Promise.all(Array.from({ length: N }, worker)); + isRunningAll = false; $('#run-all').disabled = false; $('#stop-all').disabled = true; - const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); - consoleLine('ok', `Test suite finished — ${countPassed()} passed in ${formatMs(total)}`); + const wall = Math.round(performance.now() - startedAt); + const cpu = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); + const speedup = cpu > 0 ? (cpu / Math.max(1, wall)).toFixed(2) : '1.00'; + const tail = N > 1 ? ` · ${formatMs(wall)} wall, ${formatMs(cpu)} cpu (${speedup}× speedup)` + : ` in ${formatMs(wall)}`; + consoleLine('ok', `Test suite finished — ${countPassed()} passed${tail}`); } function resetAll(){ isRunningAll = false; for (const t of tests) { - state[t.id] = { status:'idle', runtime:0 }; + state[t.id] = { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }; const art = $(`#result-${t.id}`); if (art) { art.dataset.populated = ''; art.classList.remove('is-open'); + art.classList.remove('is-pending'); + art.removeAttribute('data-pending'); const body = $(`#body-${t.id}`); if (body) body.innerHTML=''; const b = $(`#bar-${t.id}`); if (b){ b.style.width='0%'; b.style.opacity='1'; b.style.transition=''; } } refreshTreeRow(t.id); refreshResultRow(t.id); } $('#console').innerHTML = ''; + // After reset, the first test in the active spec is "pending" again — + // restore the preview so the report doesn't land empty. + refreshPendingPreview(); updateStatusbar(); consoleLine('info', 'reset · all tests returned to idle'); } @@ -255,18 +488,58 @@ // ============ STATUS / COUNTS ============ function countPassed(){ return Object.values(state).filter(s=>s.status==='passed').length; } function updateStatusbar(){ - const passed = countPassed(); - const running = Object.values(state).filter(s=>s.status==='running').length; + // Counts and runtime are scoped to the active spec — matches what the + // user is currently viewing, not the global pool of all specs. + const specTests = tests.filter(t => t.spec === activeSpec); + const passed = specTests.filter(t => state[t.id].status === 'passed').length; + const failed = specTests.filter(t => state[t.id].status === 'failed').length; + const running = specTests.filter(t => state[t.id].status === 'running').length; + const skipped = specTests.filter(t => state[t.id].status === 'skipped').length; + const xfailed = specTests.filter(t => t.fail).length; + const surprise = failed - xfailed; + const runnableCount = specTests.length - skipped - xfailed; $('#cnt-pass').textContent = passed; - $('#cnt-fail').textContent = 0; - $('#cnt-skip').textContent = 0; + $('#cnt-fail').textContent = surprise; + $('#cnt-skip').textContent = skipped; const dot = $('#status-dot'); const txt = $('#status-text'); if (running) { dot.className='dot dot--running'; txt.textContent = 'running'; } - else if (passed === tests.length) { dot.className='dot dot--pass'; txt.textContent='all passed'; } + else if (surprise > 0) { dot.className='dot dot--fail'; txt.textContent = `${surprise} failed`; } + else if (runnableCount > 0 && passed === runnableCount) { dot.className='dot dot--pass'; txt.textContent='all passed'; } else if (passed > 0) { dot.className='dot dot--pass'; txt.textContent='partial'; } else { dot.className='dot dot--idle'; txt.textContent='idle'; } - const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); + const total = specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0); $('#sb-runtime').textContent = `runtime: ${formatMs(total)}`; + updateSummaryStripe(specTests, { passed, failed, running, skipped, total }); + } + + // Tiny run-history stripe above the results list. We aggregate the current + // (last) wall time + counts for the active spec so it reads naturally as + // either "Running… ✓ 1 passed · 3 pending" or "Last run · 1.4s · 4 passed". + function updateSummaryStripe(specTests, agg){ + const el = $('#summary-stripe'); + if (!el) return; + specTests = specTests || tests.filter(t => t.spec === activeSpec); + const passed = agg ? agg.passed : specTests.filter(t => state[t.id].status === 'passed').length; + const failed = agg ? (agg.failed || 0) : specTests.filter(t => state[t.id].status === 'failed').length; + const running = agg ? agg.running : specTests.filter(t => state[t.id].status === 'running').length; + const skipped = agg ? (agg.skipped || 0) : specTests.filter(t => state[t.id].status === 'skipped').length; + const total = agg ? agg.total : specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0); + const pending = specTests.length - passed - failed - running - skipped; + let lbl, dotCls; + if (running) { lbl = 'Running'; dotCls = 'ss__dot--running'; } + else if (failed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--fail'; } + else if (passed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--pass'; } + else { lbl = 'No runs yet'; dotCls = 'ss__dot--pending'; } + const parts = [ + `${lbl}`, + ]; + if (total > 0) parts.push(`·${formatMs(total)}`); + parts.push(`·${passed} passed`); + if (failed > 0) parts.push(`·${failed} failed`); + if (running > 0) parts.push(`·${running} running`); + if (pending > 0) parts.push(`·${pending} pending`); + if (skipped > 0) parts.push(`·${skipped} skipped`); + el.innerHTML = parts.join(''); } // ============ CONSOLE ============ @@ -287,6 +560,9 @@ const id = tab.dataset.tab; $$('.tab').forEach(t=>t.classList.toggle('is-active', t===tab)); $$('.pane').forEach(p=>p.classList.toggle('is-active', p.id === `pane-${id}`)); + if (id === 'network') { + requestAnimationFrame(()=>renderNetwork()); + } }); }); } @@ -336,25 +612,144 @@ } function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } + // ============ NETWORK PANE (Gitea repos as API rows) ============ + function networkLatencyMs(name){ + let h = 2166136261; + for (let i = 0; i < name.length; i++) { + h ^= name.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return 38 + (Math.abs(h) % 145); + } + function formatNetworkBytes(n){ + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; + } + function utf8ByteLength(str){ + if (typeof TextEncoder !== 'undefined') { + try { return new TextEncoder().encode(str).length; } catch (_) {} + } + try { return encodeURIComponent(str).replace(/%../g, 'x').length; } + catch (_) { return str.length * 4; } + } + + /** Resolve list mount (handles older HTML ids + creates a node under `.network-pane` if needed). */ + function networkMount(){ + let el = document.getElementById('gitea-network'); + if (el) return el; + el = document.querySelector('#pane-network [data-repo-list]'); + if (el) return el; + el = document.querySelector('#pane-network #network'); // legacy id + if (el) return el; + el = document.querySelector('#pane-network .network-scroll'); + if (el) return el; + const pn = document.getElementById('pane-network'); + if (!pn) return null; + const host = pn.querySelector('.network-pane') || pn; + el = document.createElement('div'); + el.id = 'gitea-network'; + el.className = 'network-scroll'; + el.setAttribute('data-network-list', '1'); + const hdr = host.querySelector('.network-head'); + if (hdr) hdr.insertAdjacentElement('afterend', el); + else host.appendChild(el); + console.warn('portfolio: created #gitea-network — update deployed index.html to include this div.'); + return el; + } + + /** Expand/collapse (delegated on #pane-network; survives innerHTML swaps). */ + function onNetworkPaneClick(e){ + const btn = e.target.closest('.network-row'); + if (!btn) return; + const art = btn.closest('.network-entry'); + if (!art) return; + const panel = art.querySelector('.network-detail'); + if (!panel) return; + const open = !art.classList.contains('is-open'); + art.classList.toggle('is-open', open); + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + panel.hidden = !open; + } + + function renderNetwork(){ + const wrap = networkMount(); + if (!wrap) { + console.warn('portfolio: Network tab missing #pane-network · cannot render repo list.'); + return; + } + const GF = window.PORTFOLIO; + const repos = GF && Array.isArray(GF.giteaRepos) ? GF.giteaRepos : []; + const apiRoot = 'https://git.levkin.ca/api/v1/repos/'; + if (repos.length === 0) { + wrap.innerHTML = `
No giteaRepos in js/data.js. Hard refresh the page (Cmd+Shift+R). If deploying, redeploy after adding the array.
`; + return; + } + + try { + wrap.innerHTML = repos.map((r, i) => { + const url = apiRoot + String(r.full_name || ''); + const ms = networkLatencyMs(String(r.name || r.full_name || 'x')); + const bodyObj = { + id: i + 1, + full_name: r.full_name, + name: r.name, + html_url: r.html_url, + language: r.language || null, + description: r.description, + }; + const json = JSON.stringify(bodyObj, null, 2); + const bytes = utf8ByteLength(json); + return ` +
+ + +
`; + }).join(''); + } catch (err) { + wrap.innerHTML = `
Could not build network list (${_esc(err && err.message ? err.message : String(err))}). Check the Console for details.
`; + console.error('renderNetwork', err); + } + } + // ============ SOURCE PANE ============ function renderSource(){ + const spec = getSpec(activeSpec); + const specTests = tests.filter(t => t.spec === spec.id); const lines = [ - ['// portfolio.spec.ts — Senior SDET portfolio, expressed as a Playwright test suite'], + [`// ${spec.file} — ${specTests.length} test${specTests.length===1?'':'s'}`], ['import { test, expect } from \'@playwright/test\';'], ['import { person, experience, skills, projects } from \'./fixtures/ilia\';'], [''], - [`test.describe('${data.suite.name}', () => {`], + [`test.describe('${spec.describe}', () => {`], [''], ]; - tests.forEach(t=>{ - const tagStr = t.tags.length ? ` // ${t.tags.join(' ')}` : ''; - lines.push([` test('${t.title}', async ({ page }) => {${tagStr}`]); - t.steps.forEach(s=>{ - lines.push([` await test.step('${s.title.replace(/'/g,"\\'")}', async () => { /* ${s.dur}ms */ });`]); - }); - lines.push([` });`]); + if (specTests.length === 0) { + lines.push([' // no tests in this spec yet — see IDEAS.md']); lines.push(['']); - }); + } else { + specTests.forEach(t=>{ + const tagStr = t.tags.length ? ` // ${t.tags.join(' ')}` : ''; + lines.push([` test('${t.title}', async ({ page }) => {${tagStr}`]); + t.steps.forEach(s=>{ + lines.push([` await test.step('${s.title.replace(/'/g,"\\'")}', async () => { /* ${s.dur}ms */ });`]); + }); + lines.push([` });`]); + lines.push(['']); + }); + } lines.push(['});']); $('#source').innerHTML = lines.map((row,i)=>` @@ -366,48 +761,22 @@ // ============ RESUME DOWNLOAD ============ function downloadResume(){ - // Build a single-page HTML resume as a blob, open it in a new tab and trigger print - const p = data.person; - const html = `${p.first} ${p.last} — Resume - -

${p.first} ${p.last}

${p.email} · ${p.phone}
-
${p.title} · ${p.location} · ${p.linkedin}
-

${p.blurb}

-

Experience

-${data.experience.map(e=>` -

${e.company} — ${e.role}

-
${e.when} · ${e.where}
-
    ${e.bullets.map(b=>`
  • ${b}
  • `).join('')}
-`).join('')} -

Skills

-
    ${data.skills.map(s=>`
  • ${s.name.replace(/\*\*(.+?)\*\*/g,'$1')}
  • `).join('')}
-

Projects

-${data.projects.map(p=>`

${p.name}

${p.desc}

`).join('')} - -`; - const blob = new Blob([html], { type:'text/html' }); - const url = URL.createObjectURL(blob); - window.open(url, '_blank'); - consoleLine('ok', '⇩ resume.pdf generated — print dialog opened in new tab'); + const a = document.createElement('a'); + a.href = RESUME_PDF; + a.download = 'Ilia-Dobkin-SDET-resume.pdf'; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + consoleLine('ok', '⇩ resume.pdf downloaded'); } // ============ THEME TOGGLE ============ - // Persist via cookie (sandboxed iframes block other storage APIs) + // Three-step cycle: dark → light → hc (WCAG AAA high-contrast) → dark. + // Persist via cookie because sandboxed iframes block other storage APIs. + const THEME_CYCLE = ['dark', 'light', 'hc']; function readThemeCookie(){ - const m = document.cookie.match(/(?:^|;\s*)theme=(dark|light)/); + const m = document.cookie.match(/(?:^|;\s*)theme=(dark|light|hc)/); return m ? m[1] : null; } function writeThemeCookie(v){ @@ -418,7 +787,8 @@ ${data.projects.map(p=>`

${p.name}

{ const cur = document.documentElement.getAttribute('data-theme'); - const next = cur === 'dark' ? 'light' : 'dark'; + const idx = THEME_CYCLE.indexOf(cur); + const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length] || THEME_CYCLE[0]; document.documentElement.setAttribute('data-theme', next); writeThemeCookie(next); }); @@ -441,6 +811,223 @@ ${data.projects.map(p=>`

${p.name}

R', desc: 'Run all tests' }, + { html: 'X', desc: 'Reset · clear results' }, + { html: 'Esc', desc: 'Stop running · close overlay' }, + { html: 'T', desc: 'Toggle theme' }, + { html: '/', desc: 'Focus --grep filter' }, + { html: '19', desc: 'Run visible test by index' }, + { html: '?', desc: 'Toggle this overlay' }, + ]; + + function renderShortcuts(){ + $('#kshelp-grid').innerHTML = SHORTCUTS.map(s => + `

${s.html}${s.desc}
` + ).join(''); + } + function helpOpen(){ return !$('#kshelp').hidden; } + function openHelp(){ + const el = $('#kshelp'); + if (!el.hidden) return; + el.hidden = false; + // Defer focus so the dialog is in the a11y tree before we focus into it. + requestAnimationFrame(()=> el.querySelector('.kshelp__panel').focus()); + } + function closeHelp(){ $('#kshelp').hidden = true; } + function toggleHelp(){ helpOpen() ? closeHelp() : openHelp(); } + + function isTypingTarget(el){ + if (!el) return false; + if (el.isContentEditable) return true; + const tag = el.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + } + + function initShortcuts(){ + renderShortcuts(); + // Delegated on document so a click on the SVG glyph (or any nested element) + // still reaches us, and so we don't depend on element lookup timing. + document.addEventListener('click', e => { + if (e.target.closest('#kshelp-open')) { e.preventDefault(); toggleHelp(); return; } + if (e.target.closest('#kshelp-close')) { e.preventDefault(); closeHelp(); return; } + if (e.target.closest('#kshelp-scrim')) { closeHelp(); return; } + }); + + document.addEventListener('keydown', e => { + // Escape always wins: close overlay > stop run > blur grep. + if (e.key === 'Escape') { + if (helpOpen()) { closeHelp(); e.preventDefault(); return; } + if (isRunningAll) { isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); e.preventDefault(); return; } + if (isTypingTarget(document.activeElement)) { document.activeElement.blur(); e.preventDefault(); } + return; + } + + // `?` toggles the overlay everywhere, even from the grep input. + if (e.key === '?') { toggleHelp(); e.preventDefault(); return; } + + // Otherwise: don't hijack typing or modifier combos (Cmd+R, Ctrl+T, …). + if (isTypingTarget(e.target)) return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (helpOpen()) return; + + const k = e.key; + if (k === 'r' || k === 'R') { e.preventDefault(); runAll(); return; } + if (k === 'x' || k === 'X') { e.preventDefault(); resetAll(); return; } + if (k === 't' || k === 'T') { e.preventDefault(); $('#theme-toggle').click(); return; } + if (k === '/') { e.preventDefault(); const g = $('#grep'); g.focus(); g.select(); return; } + + if (/^[1-9]$/.test(k)) { + // Index into the active spec's currently-visible tests (so it pairs + // naturally with the open editor tab + any --grep / tag filter). + const visible = tests.filter(t => { + if (t.spec !== activeSpec) return false; + const row = $(`.test[data-id="${t.id}"]`); + return !row || row.style.display !== 'none'; + }); + const t = visible[parseInt(k, 10) - 1]; + if (t) { e.preventDefault(); runTest(t.id); } + } + }); + } + + // ============ EDITOR STRIP (open .spec.ts files) ============ + function renderEditorStrip(){ + const strip = $('#editor-strip'); + if (!strip) return; + strip.innerHTML = specs.map(s => { + const cnt = specCountFor(s.id); + const on = s.id === activeSpec; + return ` + `; + }).join(''); + strip.addEventListener('click', e => { + const btn = e.target.closest('.espec'); + if (!btn) return; + activateSpec(btn.dataset.spec); + }); + } + + function activateSpec(specId, opts){ + opts = opts || {}; + const spec = getSpec(specId); + if (!spec) return; + if (specId === activeSpec && !opts.force) return; + activeSpec = spec.id; + writeSpecCookie(activeSpec); + + // Editor strip — toggle is-active. + $$('.espec').forEach(b => { + const on = b.dataset.spec === activeSpec; + b.classList.toggle('is-active', on); + b.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + + // Sidebar tree — highlight the suite block matching the open spec. + $$('.suite[data-spec]').forEach(s => { + s.classList.toggle('is-active', s.dataset.spec === activeSpec); + }); + + // Breadcrumb + sidebar foot. + const crumb = document.querySelector('.crumb strong'); + if (crumb) crumb.textContent = spec.file; + const cnt = specCountFor(activeSpec); + const foot = $('#sidebar-foot-meta'); + if (foot) foot.textContent = `v1.0.0 · ${cnt} test${cnt===1?'':'s'}`; + + // Reskin the hero (title/sub/hint) for the active spec. + applyHeroForSpec(); + + // Re-render the source pane for the new spec. + renderSource(); + + // Re-rank the "pending preview" — different spec means a different first + // idle test. Clear the previous one, mark the new one (only if it's idle). + refreshPendingPreview(); + + // Apply grep + tag filter; refresh status counts (per active spec). + applyFilter(); + updateStatusbar(); + + // Reset scroll for panes whose content swapped (report, source). + const panes = ['#pane-report', '#pane-source']; + panes.forEach(sel => { const p = document.querySelector(sel); if (p) p.scrollTop = 0; }); + + if (!opts.silent) consoleLine('info', `▶ opened ${spec.file} (${cnt} test${cnt===1?'':'s'})`); + } + + // Recompute which result row should show the pending-preview body. Called + // when the active spec changes (each spec has its own first idle test). + function refreshPendingPreview(){ + const targetId = firstIdleId(); + $$('.result').forEach(art => { + const id = art.dataset.id; + const want = id === targetId; + const has = !!art.dataset.pending; + if (want === has) return; + const body = $(`#body-${id}`); + if (want) { + const t = tests.find(x => x.id === id); + if (!t) return; + art.classList.add('is-pending'); + art.classList.add('is-open'); + art.dataset.pending = '1'; + if (body) body.innerHTML = renderPendingBody(t); + } else { + art.classList.remove('is-pending'); + art.removeAttribute('data-pending'); + if (body) body.innerHTML = ''; + art.dataset.populated = ''; + } + }); + } + + // Per-spec hero copy. Portfolio gets the full personal landing block; + // other specs get a slimmer header so their (often single) test row + // doesn't get visually buried by a 52px name title. + const HEROES = { + portfolio: { + title: 'Ilia Dobkin', + sub: 'Senior SDET · Toronto, ON · test.describe("portfolio")', + hint: 'Click the green next to any test to run it — or press Run all above.', + }, + projects: { + title: 'Projects', + sub: 'Self-hosted infrastructure & code · test.describe("projects")', + hint: 'Run the test below to surface Levkin homelab, MCP server, and local-AI assistant cards.', + }, + skills: { + title: 'Skills & Capabilities', + sub: 'Tag-driven competencies · test.describe("skills")', + hint: 'Each test exposes a different angle — skills, daily stack, leadership, and KPIs.', + }, + playground: { + title: 'Playground', + sub: 'Interactive demos & experiments · test.describe("playground")', + hint: 'Reserved for fun stuff — see the test below for what\'s on the runway.', + }, + }; + + function applyHeroForSpec(){ + const hero = document.querySelector('.hero'); + if (!hero) return; + const conf = HEROES[activeSpec] || HEROES.portfolio; + hero.classList.toggle('hero--slim', activeSpec !== 'portfolio'); + const titleEl = hero.querySelector('.hero__title'); + const subEl = hero.querySelector('.hero__sub'); + const hintEl = hero.querySelector('.hero__hint'); + if (titleEl) titleEl.textContent = conf.title; + if (subEl) subEl.innerHTML = conf.sub; + if (hintEl) hintEl.innerHTML = conf.hint; + } + // ============ INIT ============ function init(){ initTheme(); @@ -448,9 +1035,21 @@ ${data.projects.map(p=>`

${p.name}

{ + const jump = e.target.closest('.tab-link[data-tab]'); + if (!jump) return; + e.preventDefault(); + const tabBtn = $(`.tab[data-tab="${jump.dataset.tab}"]`); + if (tabBtn) tabBtn.click(); + }); $('#grep').addEventListener('input', applyFilter); $('#run-all').addEventListener('click', runAll); @@ -476,10 +1075,68 @@ ${data.projects.map(p=>`

${p.name}

{ + const n = getWorkers(); + consoleLine('info', `--workers=${n} · next run will use ${n} parallel worker${n===1?'':'s'}`); + }); + + // Overflow menu (⋯) on the right of the editor bar. Holds --workers= + // and --headed; opens on click, closes on outside-click + Escape. + initOverflowMenu(); // Friendly nudge: animate the Run All button briefly on first load setTimeout(()=> $('#run-all').classList.add('pulse'), 600); + + // Auto-run the first test of the active spec so the report never lands + // empty — visitors see body content populated, with the runner animation + // still playing once (progress bar fill). Skip if a Reduce Motion user + // is here, or if the visitor has already touched a test in the 850ms + // since load (e.g. clicked ▶ themselves — no surprise auto-runs). + const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!prefersReduce) { + setTimeout(() => { + const anyTouched = tests.some(t => state[t.id].status !== 'idle'); + if (anyTouched) return; + const firstId = firstIdleId(); + if (firstId) runTest(firstId); + }, 850); + } + } + + function initOverflowMenu(){ + const root = $('#overflow'); + const btn = $('#overflow-btn'); + const menu = $('#overflow-menu'); + if (!root || !btn || !menu) return; + function open(){ + menu.hidden = false; + root.classList.add('is-open'); + btn.setAttribute('aria-expanded', 'true'); + } + function close(){ + menu.hidden = true; + root.classList.remove('is-open'); + btn.setAttribute('aria-expanded', 'false'); + } + btn.addEventListener('click', e => { + e.stopPropagation(); + menu.hidden ? open() : close(); + }); + // Outside-click closes; clicks inside the menu (e.g. toggling --headed + // or changing --workers) shouldn't close it — visitors usually want to + // tweak both in one go. + document.addEventListener('click', e => { + if (menu.hidden) return; + if (root.contains(e.target)) return; + close(); + }); + document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !menu.hidden) { close(); e.stopPropagation(); } + }); } document.addEventListener('DOMContentLoaded', init); })(); diff --git a/js/data.js b/js/data.js index 3fbdcf1..c2a050e 100644 --- a/js/data.js +++ b/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 `

${ PORTFOLIO.experience.map((e,i)=>` -

${_esc(e.company)} — ${_esc(e.role)}

-

${_esc(e.when)} · ${_esc(e.where)}

+

${_esc(e.company)}

+

${_esc(e.role)} · ${_esc(e.when)} · ${_esc(e.where)}

    ${e.bullets.map(b=>`
  • ${_esc(b)}
  • `).join('')}
`).join('') }
`; @@ -342,7 +446,9 @@ function renderProjects(){

${_esc(p.desc)}

${_tags(p.tags)}
`).join('') - }
`; + } +

Public code on git.levkin.ca — open the tab for an API-style request list.

+ `; } function renderStack(){ @@ -382,12 +488,12 @@ function renderMetrics(){ function renderResume(){ return `
-

Full PDF resume — generated from this same source of truth.

+

PDF resume — the same file used for applications.

- +
-

// resume renders inline using the print stylesheet — try ⌘P

+

// opens the PDF for viewing or printing from the browser

`; } @@ -395,10 +501,67 @@ function renderContact(){ const p = PORTFOLIO.person; return `
-
${p.phone}
in/idobkin
git.levkin.ca
${p.location}
`; } + +function renderPerfBudget(){ + return `
+

Lighthouse CI not wired — pending infra (see IDEAS.md)

+

Once connected, this test will assert Core Web Vitals on every deploy: LCP<2.5s, CLS<0.1, TBT<200ms.

+
`; +} + +function renderResponseTime(){ + return `
+

The Gitea API endpoint GET /api/v1/repos/ilia/portfolio exceeded the 200 ms latency budget three times in a row.

+

What happened

+
    +
  • Initial request returned in 347 ms — likely a cold-start on the VPS.
  • +
  • Retry 1: 312 ms, Retry 2: 289 ms — trending down but still above threshold.
  • +
  • The p95 across the three attempts was 316 ms.
  • +
+

Possible fixes

+
    +
  • Add a keep-alive cron to prevent cold-starts.
  • +
  • Enable response caching on the reverse proxy.
  • +
  • Raise the threshold to 350 ms if p50 is acceptable.
  • +
+
1 +2 +3 +4 +5 +6
// api.spec.ts:42 +const res = await request.get('/api/v1/repos/ilia/portfolio'); +const latency = res.headers['x-response-time']; +expect(res.status()).toBe(200); +expect(Number(latency)).toBeLessThan(200); +// ✗ Expected: < 200 · Received: 347
+
`; +} + +function renderVibe(){ + return `
+

playground.spec.ts 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.

+

On the runway

+
    +
  • Real Playwright tests of this site — meta, self-referential, satisfying. The runner that looks like a Playwright report becomes one that is verified by Playwright.
  • +
  • Recording mode — MediaRecorder API captures a 20s Run-All clip you can drop into Slack.
  • +
  • Narrative replay — click a tag, watch the runner play that storyline end-to-end.
  • +
  • Konami easter egg — because every test runner deserves one.
  • +
+
1 +2 +3 +4 +5
// playground.spec.ts +test('should pass the vibe check', async ({ page }) => { + const coffee = await kitchen.brew(); + await expect(coffee.temperature).toBeWithinRange(63, 68); +});
+
`; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..10e916e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1119 @@ +{ + "name": "portfolio-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "portfolio-tests", + "devDependencies": { + "@playwright/test": "^1.52.0", + "serve": "^14.2.6" + } + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk-template/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4860730 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..c460564 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/scripts/fetch-gitea-repos.mjs b/scripts/fetch-gitea-repos.mjs new file mode 100644 index 0000000..b6fa049 --- /dev/null +++ b/scripts/fetch-gitea-repos.mjs @@ -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).`); +})(); diff --git a/tests/portfolio.spec.ts b/tests/portfolio.spec.ts new file mode 100644 index 0000000..bc9c9d7 --- /dev/null +++ b/tests/portfolio.spec.ts @@ -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"); + }); +});