From b596b2d608a9e88ee2c35d53dd39b7c936524b31 Mon Sep 17 00:00:00 2001 From: Builder Date: Mon, 11 May 2026 22:55:48 -0400 Subject: [PATCH] Editor strip, Network/Console/Trace tabs, real Playwright tests, HC theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: - Editor tab strip above the report with per-spec scoping (sidebar tree, results, source, status counts, hero copy); cookie-persisted active spec - Status pill + workers / headed overflow menu moved from topbar into the editor bar to slim the global chrome - Summary stripe and pending-preview body so the report never lands empty - Tag bar with first-6 + "+N more" + clear; auto-run first idle test on load - Mobile drawer for the test explorer; keyboard shortcuts overlay (?) - Skipped + failed sample tests with proper icons / step rendering Tabs: - Network tab: public git.levkin.ca repos rendered as Playwright-style GET ... 200 rows with expandable JSON bodies and repo links - Trace tab: career timeline as a Gantt-style waterfall - Console tab: live run events; Source tab regenerates per active spec Theming: - Wire up high-contrast (WCAG AAA) theme: cycle dark → light → hc → dark, widen theme cookie regex to accept "hc", add HC overrides for syntax tokens and a few hardcoded "text-on-accent" sites in app.css Testing: - Add @playwright/test dev dependency + playwright.config.ts on port 3173 - tests/portfolio.spec.ts: 37 specs across 12 describe blocks - scripts/fetch-gitea-repos.mjs to refresh giteaRepos from the Gitea API Docs / housekeeping: - README rewritten to reflect editor strip, network tab, HC theme, test runner, and updated project structure - IDEAS.md trimmed to remaining roadmap; shipped items removed - .gitignore ignores stray PDFs at repo root (canonical resume in assets/) - Add assets/ilia-dobkin-resume.pdf as the canonical resume binary Co-authored-by: Cursor --- .gitignore | 7 + IDEAS.md | 93 +-- README.md | 144 ++++- assets/ilia-dobkin-resume.pdf | Bin 0 -> 110853 bytes css/app.css | 528 +++++++++++++++- css/base.css | 45 ++ index.html | 102 ++- js/app.js | 849 ++++++++++++++++++++++--- js/data.js | 181 +++++- package-lock.json | 1119 +++++++++++++++++++++++++++++++++ package.json | 15 + playwright.config.ts | 34 + scripts/fetch-gitea-repos.mjs | 95 +++ tests/portfolio.spec.ts | 443 +++++++++++++ 14 files changed, 3415 insertions(+), 240 deletions(-) create mode 100644 assets/ilia-dobkin-resume.pdf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 scripts/fetch-gitea-repos.mjs create mode 100644 tests/portfolio.spec.ts 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 0000000000000000000000000000000000000000..43ae9377dce17bc9cdde401cc4352626f83c96db GIT binary patch literal 110853 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�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

FA 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 literal 0 HcmV?d00001 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"); + }); +});