diff --git a/.env.example b/.env.example index 02348d9..898af30 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,10 @@ -# Google Gemini Variables -GOOGLE_API_KEY="" -# Default is usually gemini-2.5-flash -MODEL_NAME="gemini-2.5-flash" +# OpenCode Zen Variables (OpenAI-compatible API) +OPENCODE_API_KEY="" +# Optional base URL override; defaults to https://opencode.ai/zen/v1 +OPENCODE_BASE_URL="" +# big-pickle is a reasoning model: reasoning tokens are charged against max_tokens, +# so keep output budgets well above ~1024 or responses come back empty. +MODEL_NAME="big-pickle" AI_TIMEOUT_MS="30000" PDF_PARSE_TIMEOUT_MS="12000" COVER_LETTER_ROUTE_TIMEOUT_MS="35000" @@ -18,9 +21,28 @@ NEXT_PUBLIC_STACK_PROJECT_ID="" NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="" STACK_SECRET_SERVER_KEY="" -# Optional +# Optional — also used as the base for "tailor my resume" links in alert emails NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Optional JavaScript career-page rendering fallback, using Firecrawl's scrape API. +# Example: https://firecrawl.adityamer.dev or https://api.firecrawl.dev +# Leave empty to use provider APIs and ordinary HTML only. +FIRECRAWL_API_BASE_URL="" +# Server-only; required when your rendering service enforces API authentication. +FIRECRAWL_API_KEY="" + +# Job alert emails (Resend). Both are required before any alert is sent; +# without them the cron still scans and stores new listings, and mails them on +# the first run after the keys are added. +RESEND_API_KEY="" +# Must use a domain verified in Resend, e.g. "ATS Job Alerts " +EMAIL_FROM="" + +# Shared secret for /api/cron/scan-watchlist. Vercel Cron sends it as +# "Authorization: Bearer ". The endpoint rejects every request +# when this is unset. +CRON_SECRET="" + # Rate limiting backend (required in production unless ALLOW_IN_MEMORY_RATE_LIMIT=true) UPSTASH_REDIS_REST_URL="" UPSTASH_REDIS_REST_TOKEN="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b5c387..2bbce01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,23 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 with: - bun-version: latest + node-version: "24" + cache: true - name: Install dependencies - run: bun install --frozen-lockfile + run: vp install --frozen-lockfile + + - name: Format and lint + run: vp check - name: Lint - run: bun run lint + run: vp run lint - name: Typecheck - run: bun run typecheck + run: vp run typecheck - name: Test (Coverage) - run: bun run test:coverage + run: vp run test:coverage --maxWorkers=2 --testTimeout=15000 diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100644 index 0000000..85fb65b --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a2fc0e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# Application Tracking System + +This is a job seeker's resume preparation and job discovery app built with Next.js App Router, React, TypeScript, Tailwind CSS, Stack Auth, and Convex. Vite+ manages tooling; Next.js serves and builds the application. + +## Major Features + +| Feature | Entry point | Main responsibilities | +| --------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- | +| Dashboard | `src/app/dashboard/page.tsx` | Resume, analysis, and cover-letter counts, average match score, and quick actions. | +| Resume library | `src/app/dashboard/upload/` | PDF upload, text extraction, stored resumes, selection, and deletion. | +| Applications | `src/app/dashboard/applications/` | Persistent application pipeline, contacts, notes, follow-up queue, filtered CSV export, and watchlist imports. | +| Interview prep | `src/app/dashboard/applications/[id]/interview/` | AI practice questions, STAR outlines, saved answer notes, explicit regeneration, and revision conflict protection on each application. | +| Resume analysis | `src/app/dashboard/analysis/` | Job match scoring, skills extraction, strengths, weaknesses, recommendations, and saved results. | +| Cover letters | `src/app/dashboard/cover-letter/` | Generation with tone and length preferences, draft restoration, saved letters, and document export. | +| Resume builder | `src/app/dashboard/resume-builder/` | AI resumes from notes or saved content, optional job tailoring, seven LaTeX templates, editing, PDF rendering, and saved versions. | +| History | `src/app/dashboard/history/` | Search and filtering across saved analyses, cover letters, and tailored resumes. | +| Job alerts | `src/app/dashboard/watchlist/` | Career-page trackers, role/location/exclusion filters, editable email preferences, manual scans, and pending-alert retries. | +| Authentication | `src/stack/server.ts`, `src/app/handler/` | Stack Auth sessions and account flows. | +| Public pages | `src/app/` | Landing page, how it works, contact, privacy, and terms. | + +Treat this table as the existing product scope. When adding a feature, connect its navigation, authenticated API, storage, loading/error/empty states, and relevant history or dashboard integration. + +## Development Commands + +Use `package.json` and the lockfile as the command and dependency sources of truth. This repository declares pnpm and uses `pnpm-lock.yaml`. + +| Task | Command | +| ------------------------------------ | ---------------------- | +| Install dependencies | `vp install` | +| Start Next.js | `vp run dev` | +| Start the Convex development backend | `vp exec convex dev` | +| Build Next.js | `vp run build` | +| Serve the production build | `vp run start` | +| Format and lint checks | `vp check` | +| Strict lint script | `vp run lint` | +| TypeScript checks | `vp run typecheck` | +| Tests, single run | `vp run test` | +| Tests with coverage | `vp run test:coverage` | + +**Project-specific override:** use `vp run dev` and `vp run build` for this app. The generic Vite+ commands described below invoke Vite rather than the Next.js scripts. `vite.config.ts` currently disables lint type checking, so run the explicit typecheck script as well as `vp check` for code changes. Test configuration lives in `vitest.config.ts`, and tests live under `test/`. + +For environment setup, read `.env.example`. For deployment, career-page providers, email/cron setup, or observability, read the relevant section of `README.md`. Some README and CI commands still reference Bun; reconcile toolchain changes with `package.json` and the current lockfile. + +## Architecture and Conventions + +- Pages and API route handlers live in `src/app/`; reusable UI lives in `src/components/`, with primitives in `src/components/ui/`. Reuse the Tailwind tokens in `src/app/globals.css` and the existing toast and navigation components. +- Keep interactive browser state in client components; keep credentials, AI calls, PDF parsing, and database access on the server. The `@/` import alias resolves to `src/`. +- Validate API inputs with the contracts in `src/lib/contracts/api.ts`. Follow the route's existing response contract; shared error helpers in `src/lib/api-response.ts` include a code, message, and request ID. +- Resolve the authenticated user through `src/lib/auth.ts` and scope every user-owned read or write to that user. Preserve rate limiting on expensive routes and existing ownership checks in Convex domain modules. +- Convex tables and indexes live in `convex/schema.ts`; domain operations live in `convex/*.ts`. Server adapters in `src/lib/convex-server.ts` call `functions:*` paths, so preserve or update the compatibility exports in `convex/functions.ts` when adding operations. Regenerate Convex artifacts through its CLI when required. +- Shared API and domain types live in `src/types/`. Update the request contract, server adapter, stored data, and consuming UI together when changing a payload. + +## Feature Rules and Operational Limits + +- **AI generation:** `src/lib/gemini.ts` currently calls OpenCode Zen through an OpenAI-compatible API. Use `OPENCODE_API_KEY`, `OPENCODE_BASE_URL`, and `MODEL_NAME` from `.env.example`; the filename is historical. Preserve timeouts, retries, normalized results, user-scoped caches, and regeneration behavior when changing generation flows. +- **Drafts and duplicate requests:** inspect `src/hooks/useGenerationFlow.ts`, `src/lib/server-drafts.ts`, and `src/lib/idempotency.ts` before changing restoration or retry behavior. Server drafts and idempotency responses are currently process-local stores, so they are not durable across restarts or shared between instances. +- **Document output:** use the existing PDF parsing, LaTeX generation/rendering, and cover-letter export helpers. Keep upload limits, parsing timeouts, render failures, and downloadable output consistent with the existing flows. +- **Job discovery:** provider adapters live in `src/lib/job-sources/`. Use `src/lib/safe-fetch.ts` for user-supplied URLs and redirects. The initial scan records a baseline without alerting; later scans deduplicate postings and send new-role alerts. Preserve pending notifications when email delivery fails or is unconfigured. +- **Scheduled scans:** `/api/cron/scan-watchlist` requires `CRON_SECRET` and scans a bounded batch. Scheduling lives in `vercel.json`. Keep authentication and batching intact when changing the scan flow. +- **Configuration and logs:** keep secrets server-only and document new variables in `.env.example`. Use the observability helpers for route events and request IDs; keep resume text, job descriptions, and credentials out of logs. Production rate limiting uses Upstash; the memory fallback is local unless explicitly enabled. + +## Completing a Change + +1. Inspect the current working tree and relevant feature files before editing; preserve existing work. +2. Implement the feature through its existing UI, route, and storage patterns. Include accessible controls and useful loading, error, and empty states. +3. For code changes, run the checks above and the relevant tests; use coverage or a production build when the affected behavior requires them. For documentation-only changes, check formatting and confirm referenced paths and commands against the repository. +4. Report the result, checks performed, and any failures or required environment setup. Update this feature map when adding a major capability. + + + +# Using Vite+, the Unified Toolchain for the Web + +This project is using Vite+, a unified toolchain built on top of Vite, Rolldown, Vitest, tsdown, Oxlint, Oxfmt, and Vite Task. Vite+ wraps runtime management, package management, and frontend tooling in a single global CLI called `vp`. Vite+ is distinct from Vite, and it invokes Vite through `vp dev` and `vp build`. Run `vp help` to print a list of commands and `vp --help` for information about a specific command. + +Docs are local at `node_modules/vite-plus/docs` or online at https://viteplus.dev/guide/. + +## Built-in Commands vs Scripts + +`vp ` runs a built-in command. `vp run ` runs a `package.json` script or a `vite.config.ts` task. Scripts cannot overwrite built-ins, so `vp dev` and `vp run dev` may do different things. Check `package.json` and `vite.config.ts` first, and run `vp run ` when the project defines a script or task with that name. + +## Review Checklist + +- [ ] Run `vp install` after pulling remote changes and before getting started. +- [ ] Run `vp check` and `vp test` to format, lint, type check and test changes. +- [ ] Check if there are `vite.config.ts` tasks or `package.json` scripts necessary for validation, run via `vp run ' }], + "https://ats.example.com", + ); + + expect(html).not.toContain("'; +describe("career page rendering fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isCareerPageRenderingConfigured).mockReturnValue(true); + }); + it("renders an empty HTML page using the final safe URL", async () => { + vi.mocked(safeFetch).mockResolvedValue({ + body: "
", + url: "https://example.com/careers", + contentType: "text/html", + }); + vi.mocked(renderCareerPage).mockResolvedValue(html); + const result = await scrapeListings("https://example.com/jobs"); + expect(renderCareerPage).toHaveBeenCalledWith("https://example.com/careers"); + expect(result.listings).toEqual([ + expect.objectContaining({ title: "Engineer", fingerprint: expect.any(String) }), + ]); + }); + it("uses static jobs without requesting rendered HTML", async () => { + vi.mocked(safeFetch).mockResolvedValue({ + body: html, + url: "https://example.com/jobs", + contentType: "text/html", + }); + expect((await scrapeListings("https://example.com/jobs")).listings).toHaveLength(1); + expect(renderCareerPage).not.toHaveBeenCalled(); + }); + it("leaves empty pages alone when rendering is unconfigured", async () => { + vi.mocked(isCareerPageRenderingConfigured).mockReturnValue(false); + vi.mocked(safeFetch).mockResolvedValue({ + body: "", + url: "https://example.com/jobs", + contentType: "text/html", + }); + expect((await scrapeListings("https://example.com/jobs")).listings).toEqual([]); + expect(renderCareerPage).not.toHaveBeenCalled(); + }); + it("keeps provider APIs as the source even when a board is empty", async () => { + vi.mocked(safeFetch).mockResolvedValue({ + body: '{"jobs":[]}', + url: "https://boards-api.greenhouse.io/v1/boards/acme/jobs", + contentType: "application/json", + }); + expect((await scrapeListings("https://boards.greenhouse.io/acme")).listings).toEqual([]); + expect(renderCareerPage).not.toHaveBeenCalled(); + }); +}); diff --git a/test/lib/job-sources.test.ts b/test/lib/job-sources.test.ts new file mode 100644 index 0000000..9d2219a --- /dev/null +++ b/test/lib/job-sources.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { genericHtml } from "@/lib/job-sources/generic-html"; +import { computeFingerprint, normalizeUrl, resolveSource } from "@/lib/job-sources/index"; +import { greenhouse, lever, smartrecruiters } from "@/lib/job-sources/providers"; +import { htmlToText, humanizeSlug } from "@/lib/job-sources/shared"; + +describe("normalizeUrl", () => { + it("strips www, fragments, tracking params and trailing slashes", () => { + expect(normalizeUrl("https://WWW.Acme.com/careers/?utm_source=x&gh_src=y#top")).toBe( + "https://acme.com/careers", + ); + }); + + it("keeps meaningful query parameters and sorts them", () => { + expect(normalizeUrl("https://acme.com/jobs?b=2&a=1")).toBe("https://acme.com/jobs?a=1&b=2"); + }); +}); + +describe("resolveSource", () => { + it.each([ + ["https://boards.greenhouse.io/acme", "greenhouse", "acme"], + ["https://job-boards.greenhouse.io/acme", "greenhouse", "acme"], + ["https://acme.greenhouse.io", "greenhouse", "acme"], + ["https://jobs.lever.co/acme", "lever", "acme"], + ["https://jobs.ashbyhq.com/acme", "ashby", "acme"], + ["https://apply.workable.com/acme", "workable", "acme"], + ["https://careers.smartrecruiters.com/Acme", "smartrecruiters", "Acme"], + ["https://acme.recruitee.com", "recruitee", "acme"], + ])("detects %s as %s", (url, source, slug) => { + const match = resolveSource(url); + expect(match.source).toBe(source); + expect(match.slug).toBe(slug); + }); + + it("reads the company from an embedded Greenhouse board", () => { + const match = resolveSource("https://boards.greenhouse.io/embed/job_board?for=acme"); + expect(match.source).toBe("greenhouse"); + expect(match.endpoint).toContain("/boards/acme/jobs"); + }); + + it("routes the EU Lever region to the EU API host", () => { + expect(resolveSource("https://jobs.eu.lever.co/acme").endpoint).toContain("api.eu.lever.co"); + }); + + it("falls back to generic HTML parsing for unknown hosts", () => { + const match = resolveSource("https://acme.com/careers"); + expect(match.source).toBe("html"); + expect(match.endpoint).toBe("https://acme.com/careers"); + }); +}); + +describe("computeFingerprint", () => { + it("prefers the source id so a renamed role is not treated as new", () => { + const a = computeFingerprint("lever", { + externalId: "42", + title: "Engineer", + url: "https://a", + }); + const b = computeFingerprint("lever", { + externalId: "42", + title: "Senior Engineer", + url: "https://b", + }); + expect(a).toBe(b); + }); + + it("falls back to the canonical URL when there is no id", () => { + const a = computeFingerprint("html", { title: "Engineer", url: "https://acme.com/jobs/1/" }); + const b = computeFingerprint("html", { + title: "Engineer", + url: "https://www.acme.com/jobs/1?utm_source=email", + }); + expect(a).toBe(b); + }); + + it("falls back to the title when the URL cannot be parsed", () => { + const fingerprint = computeFingerprint("html", { title: "Staff Engineer", url: "not a url" }); + expect(fingerprint).toBe("html:title:staff engineer"); + }); +}); + +describe("greenhouse.parse", () => { + it("decodes the entity-escaped HTML description", () => { + const payload = JSON.stringify({ + jobs: [ + { + id: 7, + title: "Backend Engineer", + absolute_url: "https://boards.greenhouse.io/acme/jobs/7", + location: { name: "Remote" }, + content: "<p>Build &amp; ship services.</p>", + }, + ], + }); + + const [job] = greenhouse.parse(payload, resolveSource("https://boards.greenhouse.io/acme")); + + expect(job.title).toBe("Backend Engineer"); + expect(job.location).toBe("Remote"); + expect(job.externalId).toBe("7"); + expect(job.description).toBe("Build & ship services."); + }); + + it("skips entries without a title or url", () => { + const payload = JSON.stringify({ jobs: [{ id: 1, location: { name: "Remote" } }] }); + expect(greenhouse.parse(payload, resolveSource("https://boards.greenhouse.io/acme"))).toEqual( + [], + ); + }); + + it("throws a clear error when the API returns non-JSON", () => { + expect(() => + greenhouse.parse("nope", resolveSource("https://boards.greenhouse.io/acme")), + ).toThrow(/not JSON/); + }); +}); + +describe("lever.parse", () => { + it("maps postings to listings", () => { + const payload = JSON.stringify([ + { + id: "abc", + text: "Data Scientist", + hostedUrl: "https://jobs.lever.co/acme/abc", + categories: { location: "Berlin" }, + descriptionPlain: "Work on models.", + }, + ]); + + const [job] = lever.parse(payload, resolveSource("https://jobs.lever.co/acme")); + + expect(job).toMatchObject({ + externalId: "abc", + title: "Data Scientist", + url: "https://jobs.lever.co/acme/abc", + location: "Berlin", + }); + }); +}); + +describe("smartrecruiters.parse", () => { + it("builds posting URLs from the company slug and id", () => { + const payload = JSON.stringify({ + content: [ + { + id: "743999", + name: "Product Manager", + location: { city: "Pune", country: "India" }, + }, + ], + }); + + const [job] = smartrecruiters.parse( + payload, + resolveSource("https://careers.smartrecruiters.com/Acme"), + ); + + expect(job.url).toBe("https://jobs.smartrecruiters.com/Acme/743999"); + expect(job.location).toBe("Pune, India"); + // The list endpoint carries no body, so the app synthesizes one later. + expect(job.description).toBeUndefined(); + }); +}); + +describe("genericHtml.parse", () => { + const match = resolveSource("https://acme.com/careers"); + + it("prefers JSON-LD JobPosting entries", () => { + const html = ` + + Ignored`; + + const listings = genericHtml.parse(html, match); + + expect(listings).toHaveLength(1); + expect(listings[0]).toMatchObject({ + title: "Platform Engineer", + url: "https://acme.com/careers/platform", + location: "Austin, US", + description: "Own the platform.", + }); + }); + + it("survives a malformed JSON-LD block and still reads anchors", () => { + const html = ` + + Staff Engineer`; + + expect(genericHtml.parse(html, match)).toEqual([ + { title: "Staff Engineer", url: "https://acme.com/careers/staff-engineer" }, + ]); + }); + + it("filters navigation links and non-job paths", () => { + const html = ` + Careers + View all jobs + About us + Email us + Senior Designer`; + + expect(genericHtml.parse(html, match)).toEqual([ + { title: "Senior Designer", url: "https://acme.com/jobs/senior-designer" }, + ]); + }); + + it("deduplicates repeated links to the same posting", () => { + const html = ` + Role One + Role One`; + + expect(genericHtml.parse(html, match)).toHaveLength(1); + }); + + it("returns nothing for a page that renders its board in the browser", () => { + expect(genericHtml.parse('
', match)).toEqual([]); + }); +}); + +describe("shared helpers", () => { + it("keeps bullet structure when flattening HTML", () => { + const text = htmlToText("
  • First
  • Second

Tail

"); + expect(text).toBe("- First\n- Second\n\nTail"); + }); + + it("builds a readable company name from a slug", () => { + expect(humanizeSlug("acme-corp")).toBe("Acme Corp"); + expect(humanizeSlug("ibm")).toBe("IBM"); + }); +}); diff --git a/test/lib/observability.test.ts b/test/lib/observability.test.ts index 61752c5..f3b0a03 100644 --- a/test/lib/observability.test.ts +++ b/test/lib/observability.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { flushObservabilitySafely, @@ -6,9 +6,9 @@ import { logInfo, logSafeFileName, sanitizeLogErrorMessage, -} from '@/lib/observability'; +} from "@/lib/observability"; -describe('observability', () => { +describe("observability", () => { afterEach(() => { vi.restoreAllMocks(); delete process.env.AXIOM_TOKEN; @@ -16,46 +16,46 @@ describe('observability', () => { delete process.env.AXIOM_EDGE; }); - describe('sanitizeLogErrorMessage', () => { - it('returns undefined when details disabled', () => { - expect(sanitizeLogErrorMessage(new Error('x'), false)).toBeUndefined(); + describe("sanitizeLogErrorMessage", () => { + it("returns undefined when details disabled", () => { + expect(sanitizeLogErrorMessage(new Error("x"), false)).toBeUndefined(); }); - it('returns message slice when details enabled', () => { - expect(sanitizeLogErrorMessage(new Error('boom'), true)).toBe('boom'); + it("returns message slice when details enabled", () => { + expect(sanitizeLogErrorMessage(new Error("boom"), true)).toBe("boom"); }); - it('returns unknown_error for non-Error when details enabled', () => { - expect(sanitizeLogErrorMessage('nope', true)).toBe('unknown_error'); + it("returns unknown_error for non-Error when details enabled", () => { + expect(sanitizeLogErrorMessage("nope", true)).toBe("unknown_error"); }); }); - describe('logSafeFileName', () => { - it('strips path segments', () => { - expect(logSafeFileName('C:\\Users\\resume.pdf')).toBe('resume.pdf'); + describe("logSafeFileName", () => { + it("strips path segments", () => { + expect(logSafeFileName("C:\\Users\\resume.pdf")).toBe("resume.pdf"); }); - it('returns unknown for empty basename', () => { - expect(logSafeFileName('')).toBe('unknown'); + it("returns unknown for empty basename", () => { + expect(logSafeFileName("")).toBe("unknown"); }); }); - describe('logInfo / logError without Axiom env', () => { - it('writes to stdout without throwing', () => { - const info = vi.spyOn(console, 'info').mockImplementation(() => {}); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + describe("logInfo / logError without Axiom env", () => { + it("writes to stdout without throwing", () => { + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - logInfo({ event: 'test.info' }); - logError({ event: 'test.error' }); + logInfo({ event: "test.info" }); + logError({ event: "test.error" }); expect(info).toHaveBeenCalled(); expect(errorSpy).toHaveBeenCalled(); }); }); - describe('flushObservabilitySafely', () => { - it('does not throw when Axiom is not configured', () => { + describe("flushObservabilitySafely", () => { + it("does not throw when Axiom is not configured", () => { expect(() => flushObservabilitySafely()).not.toThrow(); }); }); -}); \ No newline at end of file +}); diff --git a/test/lib/pdf-parser.test.ts b/test/lib/pdf-parser.test.ts index 0508992..cc80ad7 100644 --- a/test/lib/pdf-parser.test.ts +++ b/test/lib/pdf-parser.test.ts @@ -1,56 +1,56 @@ -import { describe, expect,it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; -import { decodePdfTextRun, validatePDFFile } from '@/lib/pdf-parser'; +import { decodePdfTextRun, validatePDFFile } from "@/lib/pdf-parser"; -describe('pdf-parser', () => { - describe('decodePdfTextRun', () => { - it('decodes valid URI-encoded text', () => { - expect(decodePdfTextRun('hello%20world')).toBe('hello world'); - }); +describe("pdf-parser", () => { + describe("decodePdfTextRun", () => { + it("decodes valid URI-encoded text", () => { + expect(decodePdfTextRun("hello%20world")).toBe("hello world"); + }); - it('treats + as a space', () => { - expect(decodePdfTextRun('hello+world')).toBe('hello world'); - }); + it("treats + as a space", () => { + expect(decodePdfTextRun("hello+world")).toBe("hello world"); + }); - it('returns raw token when decode fails', () => { - expect(decodePdfTextRun('bad%ZZ')).toBe('bad%ZZ'); - }); + it("returns raw token when decode fails", () => { + expect(decodePdfTextRun("bad%ZZ")).toBe("bad%ZZ"); + }); - it('returns empty string for empty token', () => { - expect(decodePdfTextRun('')).toBe(''); - }); + it("returns empty string for empty token", () => { + expect(decodePdfTextRun("")).toBe(""); }); + }); - describe('validatePDFFile', () => { - it('should return valid for a proper PDF file', () => { - const file = new File([''], 'test.pdf', { type: 'application/pdf' }); - // Mock size - Object.defineProperty(file, 'size', { value: 1024 * 1024 }); + describe("validatePDFFile", () => { + it("should return valid for a proper PDF file", () => { + const file = new File([""], "test.pdf", { type: "application/pdf" }); + // Mock size + Object.defineProperty(file, "size", { value: 1024 * 1024 }); - const result = validatePDFFile(file); - expect(result.valid).toBe(true); - }); + const result = validatePDFFile(file); + expect(result.valid).toBe(true); + }); - it('should return invalid for non-pdf files', () => { - const file = new File([''], 'test.txt', { type: 'text/plain' }); - const result = validatePDFFile(file); - expect(result.valid).toBe(false); - expect(result.error).toBe('File must be a PDF'); - }); + it("should return invalid for non-pdf files", () => { + const file = new File([""], "test.txt", { type: "text/plain" }); + const result = validatePDFFile(file); + expect(result.valid).toBe(false); + expect(result.error).toBe("File must be a PDF"); + }); - it('should return invalid when no file', () => { - const result = validatePDFFile(null as unknown as File); - expect(result.valid).toBe(false); - expect(result.error).toBe('No file provided'); - }); + it("should return invalid when no file", () => { + const result = validatePDFFile(null as unknown as File); + expect(result.valid).toBe(false); + expect(result.error).toBe("No file provided"); + }); - it('should return invalid for large files', () => { - const file = new File([''], 'large.pdf', { type: 'application/pdf' }); - Object.defineProperty(file, 'size', { value: 25 * 1024 * 1024 }); // 25MB + it("should return invalid for large files", () => { + const file = new File([""], "large.pdf", { type: "application/pdf" }); + Object.defineProperty(file, "size", { value: 25 * 1024 * 1024 }); // 25MB - const result = validatePDFFile(file); - expect(result.valid).toBe(false); - expect(result.error).toContain('File size must be less than 20MB'); - }); + const result = validatePDFFile(file); + expect(result.valid).toBe(false); + expect(result.error).toContain("File size must be less than 20MB"); }); + }); }); diff --git a/test/lib/rendered-career-page.test.ts b/test/lib/rendered-career-page.test.ts new file mode 100644 index 0000000..a7cb034 --- /dev/null +++ b/test/lib/rendered-career-page.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { isCareerPageRenderingConfigured, renderCareerPage } from "@/lib/rendered-career-page"; +import { assertSafeUrl } from "@/lib/safe-fetch"; + +vi.mock("@/lib/safe-fetch", () => ({ + assertSafeUrl: vi.fn().mockResolvedValue(new URL("https://example.com")), +})); +const fetchMock = vi.fn(); + +describe("rendered career-page fallback", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + vi.stubEnv("FIRECRAWL_API_BASE_URL", "https://render.example.com"); + vi.stubEnv("FIRECRAWL_API_KEY", "test-key"); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + it("is optional and uses the configured service with server authentication", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ success: true, data: { html: "Engineer" } }), + ), + ); + expect(await renderCareerPage("https://example.com/careers")).toContain("Engineer"); + expect(fetchMock).toHaveBeenCalledWith( + "https://render.example.com/v2/scrape", + expect.objectContaining({ + redirect: "error", + headers: expect.objectContaining({ Authorization: "Bearer test-key" }), + }), + ); + vi.stubEnv("FIRECRAWL_API_BASE_URL", ""); + expect(isCareerPageRenderingConfigured()).toBe(false); + }); + it("rejects unsafe target URLs before contacting the rendering service", async () => { + vi.mocked(assertSafeUrl).mockRejectedValueOnce(new Error("Unsafe URL")); + await expect(renderCareerPage("http://127.0.0.1")).rejects.toThrow("Unsafe URL"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("rejects service errors without exposing credentials", async () => { + fetchMock.mockResolvedValue(new Response("denied", { status: 403 })); + await expect(renderCareerPage("https://example.com/careers")).rejects.toThrow( + "service status 403", + ); + }); +}); diff --git a/test/lib/resume-latex.test.ts b/test/lib/resume-latex.test.ts index 4b68c52..5d4a53f 100644 --- a/test/lib/resume-latex.test.ts +++ b/test/lib/resume-latex.test.ts @@ -1,47 +1,101 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; -import { buildLatexResume, escapeLatex } from '@/lib/resume-latex'; +import { tailoredResumeRequestSchema } from "@/lib/contracts/api"; +import { + buildLatexResume, + escapeLatex, + RESUME_TEMPLATE_OPTIONS, + TEMPLATE_PREVIEW_DATA, +} from "@/lib/resume-latex"; +import { BUILT_IN_RESUME_TEMPLATE_IDS } from "@/types/resume-templates"; -describe('resume-latex', () => { - it('escapes LaTeX special characters', () => { - const value = '50% growth & $1000 #1 _dev_'; +describe("resume-latex", () => { + it("offers seven unique, supported layouts", () => { + expect(RESUME_TEMPLATE_OPTIONS.map((template) => template.id)).toEqual([ + ...BUILT_IN_RESUME_TEMPLATE_IDS, + ]); + expect(new Set(RESUME_TEMPLATE_OPTIONS.map((template) => template.id)).size).toBe(7); + expect( + new Set(BUILT_IN_RESUME_TEMPLATE_IDS.map((id) => buildLatexResume(id, TEMPLATE_PREVIEW_DATA))) + .size, + ).toBe(7); + }); + it.each(BUILT_IN_RESUME_TEMPLATE_IDS)( + "preserves supplied content in %s and accepts general-resume input", + (templateId) => { + const output = buildLatexResume(templateId, TEMPLATE_PREVIEW_DATA); + for (const value of [ + TEMPLATE_PREVIEW_DATA.fullName!, + TEMPLATE_PREVIEW_DATA.summary, + TEMPLATE_PREVIEW_DATA.certifications[0], + TEMPLATE_PREVIEW_DATA.additional[0], + ]) { + expect(output).toContain(escapeLatex(value)); + } + expect(output).toContain("\\begin{document}"); + expect(output).toContain("\\end{document}"); + expect( + tailoredResumeRequestSchema.parse({ resumeText: "My career details", templateId }) + .jobDescription, + ).toBe(""); + }, + ); + it("orders graduate education before experience and keeps sidebar content in two columns", () => { + const graduate = buildLatexResume("graduate-focus", TEMPLATE_PREVIEW_DATA); + expect(graduate.indexOf("\\section*{Education}")).toBeLessThan( + graduate.indexOf("\\section*{Experience}"), + ); + expect(buildLatexResume("modern-sidebar", TEMPLATE_PREVIEW_DATA)).toContain( + "\\begin{paracol}{2}", + ); + expect( + RESUME_TEMPLATE_OPTIONS.find((template) => template.id === "modern-sidebar")?.atsFriendly, + ).toBe(false); + }); + it("escapes literal backslashes in one pass without escaping generated command braces", () => { + expect(escapeLatex("C:\\folder {file} ~ ^")).toBe( + "C:\\textbackslash{}folder \\{file\\} \\textasciitilde{} \\textasciicircum{}", + ); + }); + it("escapes LaTeX special characters", () => { + const value = "50% growth & $1000 #1 _dev_"; const escaped = escapeLatex(value); - expect(escaped).toContain('\\%'); - expect(escaped).toContain('\\&'); - expect(escaped).toContain('\\$'); - expect(escaped).toContain('\\#'); - expect(escaped).toContain('\\_'); + expect(escaped).toContain("\\%"); + expect(escaped).toContain("\\&"); + expect(escaped).toContain("\\$"); + expect(escaped).toContain("\\#"); + expect(escaped).toContain("\\_"); }); - it('builds latex document for template', () => { - const output = buildLatexResume('jake-classic', { - fullName: 'Ada Lovelace', - email: 'ada@example.com', - summary: 'Engineer building reliable systems.', - skills: ['TypeScript', 'Node.js'], + it("builds latex document for template", () => { + const output = buildLatexResume("jake-classic", { + fullName: "Ada Lovelace", + email: "ada@example.com", + summary: "Engineer building reliable systems.", + skills: ["TypeScript", "Node.js"], experience: [ { - title: 'Software Engineer', - subtitle: 'Acme', - date: '2022-2025', - location: 'Remote', - bullets: ['Improved API latency by 30%'], + title: "Software Engineer", + subtitle: "Acme", + date: "2022-2025", + location: "Remote", + bullets: ["Improved API latency by 30%"], }, ], projects: [], education: [], certifications: [], additional: [], - keywordsUsed: ['api'], - targetTitle: 'Senior Engineer', + keywordsUsed: ["api"], + targetTitle: "Senior Engineer", }); - expect(output).toContain('\\documentclass'); - expect(output).toContain('Ada Lovelace'); - expect(output).toContain('Software Engineer'); - expect(output).toContain('\\section{Experience}'); - expect(output).toContain('\\section{Technical Skills}'); - expect(output).toContain('\\end{document}'); + expect(output).toContain("\\documentclass"); + expect(output).toContain("Ada Lovelace"); + expect(output).toContain("Software Engineer"); + expect(output).toContain("\\section{Experience}"); + expect(output).toContain("\\section{Technical Skills}"); + expect(output).toContain("\\end{document}"); }); }); diff --git a/test/lib/safe-fetch.test.ts b/test/lib/safe-fetch.test.ts new file mode 100644 index 0000000..ac628f0 --- /dev/null +++ b/test/lib/safe-fetch.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { assertSafeUrl, isPrivateAddress, UnsafeUrlError } from "@/lib/safe-fetch"; + +describe("isPrivateAddress", () => { + it.each([ + "127.0.0.1", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", // cloud metadata + "100.64.0.1", + "0.0.0.0", + "::1", + "fc00::1", + "fe80::1", + "::ffff:127.0.0.1", + "::ffff:7f00:1", + "0:0:0:0:0:ffff:a00:1", + "::ffff:a9fe:a9fe", + ])("treats %s as private", (ip) => { + expect(isPrivateAddress(ip)).toBe(true); + }); + + it.each(["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:4700::1111"])( + "treats %s as public", + (ip) => { + expect(isPrivateAddress(ip)).toBe(false); + }, + ); + + it("treats anything that is not an address literal as unsafe", () => { + expect(isPrivateAddress("example.com")).toBe(true); + }); +}); + +describe("assertSafeUrl", () => { + it.each([ + ["file:///etc/passwd", /http and https/], + ["ftp://acme.com/jobs", /http and https/], + ["https://acme.com:22/jobs", /port/], + ["http://localhost/jobs", /not reachable/], + ["http://build.local/jobs", /not reachable/], + ["http://127.0.0.1/jobs", /not reachable/], + ["http://169.254.169.254/latest/meta-data", /not reachable/], + ["http://[::ffff:127.0.0.1]/jobs", /not reachable/], + ["http://[0:0:0:0:0:ffff:a00:1]/jobs", /not reachable/], + ["https://user:password@8.8.8.8/jobs", /credentials/], + ["not-a-url", /valid URL/], + ])("rejects %s", async (url, message) => { + await expect(assertSafeUrl(url)).rejects.toThrow(message); + }); + + it("rejects with UnsafeUrlError so routes can map it to a 400", async () => { + await expect(assertSafeUrl("http://10.0.0.5/jobs")).rejects.toBeInstanceOf(UnsafeUrlError); + }); + + it("accepts a public address literal", async () => { + const url = await assertSafeUrl("https://8.8.8.8/careers"); + expect(url.hostname).toBe("8.8.8.8"); + }); +}); diff --git a/test/lib/utils.test.ts b/test/lib/utils.test.ts index b0f8b6d..30a2a34 100644 --- a/test/lib/utils.test.ts +++ b/test/lib/utils.test.ts @@ -1,87 +1,94 @@ -import { describe, expect,it } from 'vitest'; +import { describe, expect, it } from "vite-plus/test"; -import { cn, createHash, formatFileSize, LRUCache, sanitizeFileName, truncateText } from '@/lib/utils'; +import { + cn, + createHash, + formatFileSize, + LRUCache, + sanitizeFileName, + truncateText, +} from "@/lib/utils"; -describe('utils', () => { - describe('cn', () => { - it('should merge class names', () => { - expect(cn('a', 'b')).toBe('a b'); - expect(cn('a', { b: true, c: false })).toBe('a b'); - }); +describe("utils", () => { + describe("cn", () => { + it("should merge class names", () => { + expect(cn("a", "b")).toBe("a b"); + expect(cn("a", { b: true, c: false })).toBe("a b"); + }); - it('should handle tailwind merges', () => { - expect(cn('px-2 py-2', 'px-4')).toBe('py-2 px-4'); - }); + it("should handle tailwind merges", () => { + expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4"); }); + }); - describe('LRUCache', () => { - it('should store and retrieve values', () => { - const cache = new LRUCache(2, 100); - cache.set('a', '1'); - expect(cache.get('a')).toBe('1'); - }); + describe("LRUCache", () => { + it("should store and retrieve values", () => { + const cache = new LRUCache(2, 100); + cache.set("a", "1"); + expect(cache.get("a")).toBe("1"); + }); - it('should evict least recently used items', () => { - const cache = new LRUCache(2, 100); - cache.set('a', '1'); - cache.set('b', '2'); - cache.get('a'); // Mark 'a' as recently used - cache.set('c', '3'); // Should evict 'b' + it("should evict least recently used items", () => { + const cache = new LRUCache(2, 100); + cache.set("a", "1"); + cache.set("b", "2"); + cache.get("a"); // Mark 'a' as recently used + cache.set("c", "3"); // Should evict 'b' - expect(cache.get('a')).toBe('1'); - expect(cache.get('c')).toBe('3'); - expect(cache.get('b')).toBeNull(); - }); + expect(cache.get("a")).toBe("1"); + expect(cache.get("c")).toBe("3"); + expect(cache.get("b")).toBeNull(); + }); - it('should expire items after TTL', async () => { - const cache = new LRUCache(2, 0.1); // 100ms - cache.set('a', '1'); + it("should expire items after TTL", async () => { + const cache = new LRUCache(2, 0.1); // 100ms + cache.set("a", "1"); - await new Promise(resolve => setTimeout(resolve, 150)); + await new Promise((resolve) => setTimeout(resolve, 150)); - expect(cache.get('a')).toBeNull(); - }); + expect(cache.get("a")).toBeNull(); }); + }); - describe('createHash', () => { - it('should create a consistent hash', () => { - const input = 'test string'; - const hash1 = createHash(input); - const hash2 = createHash(input); - expect(hash1).toBe(hash2); - expect(typeof hash1).toBe('string'); - }); + describe("createHash", () => { + it("should create a consistent hash", () => { + const input = "test string"; + const hash1 = createHash(input); + const hash2 = createHash(input); + expect(hash1).toBe(hash2); + expect(typeof hash1).toBe("string"); + }); - it('should create different hashes for different strings', () => { - expect(createHash('a')).not.toBe(createHash('b')); - }); + it("should create different hashes for different strings", () => { + expect(createHash("a")).not.toBe(createHash("b")); }); + }); - describe('formatFileSize', () => { - it('should format bytes correctly', () => { - expect(formatFileSize(0)).toBe('0 Bytes'); - expect(formatFileSize(1024)).toBe('1 KB'); - expect(formatFileSize(1024 * 1024)).toBe('1 MB'); - expect(formatFileSize(1234567)).toBe('1.18 MB'); - }); + describe("formatFileSize", () => { + it("should format bytes correctly", () => { + expect(formatFileSize(0)).toBe("0 Bytes"); + expect(formatFileSize(1024)).toBe("1 KB"); + expect(formatFileSize(1024 * 1024)).toBe("1 MB"); + expect(formatFileSize(1234567)).toBe("1.18 MB"); }); + }); - describe('truncateText', () => { - it('should truncate text correctly', () => { - expect(truncateText('hello world', 5)).toBe('hello...'); - expect(truncateText('hi', 5)).toBe('hi'); - }); + describe("truncateText", () => { + it("should truncate text correctly", () => { + expect(truncateText("hello world", 5)).toBe("hello..."); + expect(truncateText("hi", 5)).toBe("hi"); }); + }); - describe('sanitizeFileName', () => { - it('should normalize and sanitize names', () => { - expect(sanitizeFileName('Cover Letter - ACME Inc.')).toBe('cover-letter-acme-inc'); - expect(sanitizeFileName(' Weird / Name <> "x" ')).toBe('weird-name-x'); - }); + describe("sanitizeFileName", () => { + it("should normalize and sanitize names", () => { + expect(sanitizeFileName("Cover Letter - ACME Inc.")).toBe("cover-letter-acme-inc"); + expect(sanitizeFileName(' Weird / Name <> "x" ')).toBe("weird-name-x"); + }); - it('should use fallback for invalid names', () => { - expect(sanitizeFileName('....', 'file')).toBe('file'); - expect(sanitizeFileName('***', 'file')).toBe('file'); - }); + it("should use fallback for invalid names", () => { + expect(sanitizeFileName("....", "file")).toBe("file"); + expect(sanitizeFileName("***", "file")).toBe("file"); }); + }); }); diff --git a/test/lib/watchlist-filters.test.ts b/test/lib/watchlist-filters.test.ts new file mode 100644 index 0000000..511aa7f --- /dev/null +++ b/test/lib/watchlist-filters.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { watchlistCreateSchema, watchlistUpdateSchema } from "@/lib/contracts/api"; +import { matchesWatchFilters, parseWatchTerms } from "@/lib/watchlist-filters"; + +describe("site watch filters", () => { + it("keeps existing watches unfiltered by default", () => { + expect(matchesWatchFilters({ title: "Engineer" }, {})).toBe(true); + }); + it("accepts any role term and location but applies both groups", () => { + const preferences = { roleKeywords: ["engineer", "designer"], locations: ["remote", "india"] }; + expect( + matchesWatchFilters({ title: "Frontend ENGINEER", location: "Remote, India" }, preferences), + ).toBe(true); + expect( + matchesWatchFilters({ title: "Designer", location: "Bengaluru, India" }, preferences), + ).toBe(true); + expect(matchesWatchFilters({ title: "Engineer", location: "London" }, preferences)).toBe(false); + expect(matchesWatchFilters({ title: "Accountant", location: "India" }, preferences)).toBe( + false, + ); + expect(matchesWatchFilters({ title: "Engineer" }, preferences)).toBe(false); + }); + it("gives exclusions priority over included role keywords", () => { + expect( + matchesWatchFilters( + { title: "Senior Engineer" }, + { roleKeywords: ["Engineer"], excludeKeywords: ["senior"] }, + ), + ).toBe(false); + }); + it("trims, deduplicates and clears comma-separated preferences", () => { + expect(parseWatchTerms(" Engineer, ,Engineer, Designer ")).toEqual(["Engineer", "Designer"]); + expect(parseWatchTerms(" , ")).toEqual([]); + }); + it("validates bounded settings and permits in-app-only watches", () => { + expect( + watchlistCreateSchema.safeParse({ + url: "https://example.com/careers", + emailAlerts: false, + locations: ["Remote"], + }).success, + ).toBe(true); + expect(watchlistUpdateSchema.safeParse({ roleKeywords: [] }).success).toBe(true); + expect(watchlistUpdateSchema.safeParse({}).success).toBe(false); + expect(watchlistUpdateSchema.safeParse({ locations: Array(11).fill("Remote") }).success).toBe( + false, + ); + expect(watchlistUpdateSchema.safeParse({ excludeKeywords: [""] }).success).toBe(false); + expect(watchlistUpdateSchema.safeParse({ notifyEmail: "another@example.com" }).success).toBe( + false, + ); + }); +}); diff --git a/test/lib/watchlist-scan.test.ts b/test/lib/watchlist-scan.test.ts new file mode 100644 index 0000000..247f14a --- /dev/null +++ b/test/lib/watchlist-scan.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import type { CompanyWatch } from "@/lib/convex-server"; +import { markListingsNotified, recordScanFailure, recordScanResult } from "@/lib/convex-server"; +import { isEmailConfigured, sendEmail } from "@/lib/email"; +import { scrapeListings } from "@/lib/job-sources"; +import { scanWatch, scanWatches } from "@/lib/watchlist-scan"; + +vi.mock("@/lib/convex-server", () => ({ + recordScanResult: vi.fn(), + recordScanFailure: vi.fn().mockResolvedValue(true), + markListingsNotified: vi.fn().mockResolvedValue(1), +})); + +// Only the two side-effecting exports are stubbed; `escapeHtml` lives in this +// module too and the email renderer needs the real one. +vi.mock("@/lib/email", async (importOriginal) => ({ + ...(await importOriginal()), + isEmailConfigured: vi.fn().mockReturnValue(true), + sendEmail: vi.fn().mockResolvedValue({ id: "email-1" }), +})); + +vi.mock("@/lib/job-sources", () => ({ + scrapeListings: vi.fn(), +})); + +vi.mock("@/lib/observability", () => ({ + logInfo: vi.fn(), + logError: vi.fn(), +})); + +function watch(overrides: Partial = {}): CompanyWatch { + return { + _id: "watch-1", + _creationTime: Date.now(), + userId: "user-1", + url: "https://boards.greenhouse.io/acme", + companyName: "Acme", + source: "greenhouse", + notifyEmail: "someone@example.com", + active: true, + seeded: true, + ...overrides, + }; +} + +const listing = { + id: "listing-1", + title: "Senior Engineer", + url: "https://boards.greenhouse.io/acme/jobs/1", + companyName: "Acme", + location: "Remote", + description: "Build things.", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isEmailConfigured).mockReturnValue(true); + vi.mocked(scrapeListings).mockResolvedValue({ + source: "greenhouse", + label: "Greenhouse", + slug: "acme", + companyName: "Acme", + endpoint: "https://boards-api.greenhouse.io/v1/boards/acme/jobs?content=true", + listings: [ + { + fingerprint: "greenhouse:id:1", + title: "Senior Engineer", + url: "https://boards.greenhouse.io/acme/jobs/1", + }, + ], + }); +}); + +describe("scanWatch", () => { + it("keeps all scraped postings as the baseline but only returns matching alerts", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + const result = await scanWatch(watch({ roleKeywords: ["Designer"] })); + expect(recordScanResult).toHaveBeenCalledWith("watch-1", expect.any(Array), 0); + expect(result.newListings).toEqual([]); + expect(result.outcome.matchingCount).toBe(0); + }); + it("reports new postings returned by the diff", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + + const result = await scanWatch(watch()); + + expect(result.outcome.status).toBe("ok"); + expect(result.outcome.newCount).toBe(1); + expect(result.newListings).toEqual([listing]); + }); + + it("marks the baseline pass as seeded and reports nothing new", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [], seeded: false }); + + const result = await scanWatch(watch({ seeded: false })); + + expect(result.outcome.status).toBe("seeded"); + expect(result.newListings).toEqual([]); + }); + + it("records a failure and does not throw when the scrape fails", async () => { + vi.mocked(scrapeListings).mockRejectedValue(new Error("Request failed with status 404")); + + const result = await scanWatch(watch()); + + expect(result.outcome.status).toBe("error"); + expect(result.outcome.error).toContain("404"); + expect(vi.mocked(recordScanFailure)).toHaveBeenCalledWith( + "watch-1", + expect.stringContaining("404"), + ); + expect(vi.mocked(recordScanResult)).not.toHaveBeenCalled(); + }); + + it("flags an unreadable JavaScript board on the first scan of a generic page", async () => { + vi.mocked(scrapeListings).mockResolvedValue({ + source: "html", + label: "Web page", + slug: "acme.com", + companyName: "Acme", + endpoint: "https://acme.com/careers", + listings: [], + }); + + const result = await scanWatch(watch({ source: "html", seeded: false })); + + expect(result.outcome.status).toBe("error"); + expect(result.outcome.error).toContain("JavaScript"); + }); + + it("accepts an empty result on a seeded page, since the company may have closed every role", async () => { + vi.mocked(scrapeListings).mockResolvedValue({ + source: "html", + label: "Web page", + slug: "acme.com", + companyName: "Acme", + endpoint: "https://acme.com/careers", + listings: [], + }); + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [], seeded: true }); + + const result = await scanWatch(watch({ source: "html", seeded: true })); + + expect(result.outcome.status).toBe("ok"); + }); +}); + +describe("scanWatches", () => { + it("scans in-app-only watches without sending or marking their pending email alerts", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + const summary = await scanWatches([watch({ emailAlerts: false })]); + expect(summary.scanned).toBe(1); + expect(sendEmail).not.toHaveBeenCalled(); + expect(markListingsNotified).not.toHaveBeenCalled(); + }); + it("sends one digest per recipient and marks those listings notified", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + + const summary = await scanWatches([watch(), watch({ _id: "watch-2", companyName: "Globex" })]); + + expect(vi.mocked(sendEmail)).toHaveBeenCalledTimes(1); + expect(vi.mocked(sendEmail).mock.calls[0][0].to).toBe("someone@example.com"); + expect(summary.emailsSent).toBe(1); + expect(vi.mocked(markListingsNotified)).toHaveBeenCalledWith(["listing-1", "listing-1"]); + }); + + it("does not mark listings notified when the send fails", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + vi.mocked(sendEmail).mockRejectedValue(new Error("Resend API error (422)")); + + const summary = await scanWatches([watch()]); + + expect(summary.emailsSent).toBe(0); + expect(vi.mocked(markListingsNotified)).not.toHaveBeenCalled(); + }); + + it("leaves listings pending when email is not configured", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [listing], seeded: true }); + vi.mocked(isEmailConfigured).mockReturnValue(false); + + const summary = await scanWatches([watch()]); + + expect(vi.mocked(sendEmail)).not.toHaveBeenCalled(); + expect(vi.mocked(markListingsNotified)).not.toHaveBeenCalled(); + expect(summary.newListings).toBe(1); + }); + + it("sends nothing when no postings are new", async () => { + vi.mocked(recordScanResult).mockResolvedValue({ newListings: [], seeded: true }); + + const summary = await scanWatches([watch()]); + + expect(vi.mocked(sendEmail)).not.toHaveBeenCalled(); + expect(summary).toMatchObject({ scanned: 1, newListings: 0, emailsSent: 0 }); + }); + + it("returns an empty summary for an empty batch", async () => { + await expect(scanWatches([])).resolves.toMatchObject({ scanned: 0, emailsSent: 0 }); + expect(vi.mocked(scrapeListings)).not.toHaveBeenCalled(); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index e19b6af..81382d4 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ -import '@testing-library/jest-dom/vitest'; +import "@testing-library/jest-dom/vitest"; -process.env.GOOGLE_API_KEY ||= 'test-google-api-key'; -process.env.NEXT_PUBLIC_CONVEX_URL ||= 'https://test-project.convex.cloud'; +process.env.OPENCODE_API_KEY ||= "test-opencode-api-key"; +process.env.NEXT_PUBLIC_CONVEX_URL ||= "https://test-project.convex.cloud"; diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..6978535 --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/scan-watchlist", + "schedule": "0 7 * * *" + } + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..d257534 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,1251 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + staged: { + "*": "vp check --fix", + }, + fmt: {}, + lint: { + plugins: ["oxc", "typescript", "unicorn", "react", "nextjs"], + jsPlugins: [ + "eslint-plugin-simple-import-sort", + { + name: "vite-plus", + specifier: "vite-plus/oxlint-plugin", + }, + ], + categories: { + correctness: "warn", + }, + env: { + builtin: true, + }, + ignorePatterns: [ + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + "coverage/**", + "convex/_generated/**", + ], + rules: { + "no-array-constructor": "error", + "no-unused-expressions": "error", + "no-unused-vars": "warn", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "no-empty": "error", + "nextjs/no-html-link-for-pages": "error", + "nextjs/no-sync-scripts": "error", + "nextjs/inline-script-id": "error", + "nextjs/no-assign-module-variable": "error", + "nextjs/no-document-import-in-page": "error", + "nextjs/no-duplicate-head": "error", + "nextjs/no-head-import-in-document": "error", + "nextjs/no-script-component-in-head": "error", + "typescript/ban-ts-comment": "error", + "typescript/no-duplicate-enum-values": "error", + "typescript/no-empty-object-type": "error", + "typescript/no-explicit-any": "error", + "typescript/no-extra-non-null-assertion": "error", + "typescript/no-misused-new": "error", + "typescript/no-namespace": "error", + "typescript/no-non-null-asserted-optional-chain": "error", + "typescript/no-require-imports": "error", + "typescript/no-this-alias": "error", + "typescript/no-unnecessary-type-constraint": "error", + "typescript/no-unsafe-declaration-merging": "error", + "typescript/no-unsafe-function-type": "error", + "typescript/no-wrapper-object-types": "error", + "typescript/prefer-as-const": "error", + "typescript/prefer-namespace-keyword": "error", + "typescript/triple-slash-reference": "error", + "vite-plus/prefer-vite-plus-imports": "error", + }, + overrides: [ + { + files: ["**/*.{js,jsx,mjs,ts,tsx,mts,cts}"], + rules: { + "react/display-name": "error", + "react/jsx-key": "error", + "react/jsx-no-comment-textnodes": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-target-blank": "off", + "react/jsx-no-undef": "error", + "react/no-children-prop": "error", + "react/no-danger-with-children": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unescaped-entities": "error", + "react/no-unknown-property": "off", + "react/no-unsafe": "off", + "react/react-in-jsx-scope": "off", + "react/require-render-return": "error", + "import/no-anonymous-default-export": "warn", + "jsx-a11y/alt-text": [ + "warn", + { + elements: ["img"], + img: ["Image"], + }, + ], + "jsx-a11y/aria-props": "warn", + "jsx-a11y/aria-proptypes": "warn", + "jsx-a11y/aria-unsupported-elements": "warn", + "jsx-a11y/role-has-required-aria-props": "warn", + "jsx-a11y/role-supports-aria-props": "warn", + "nextjs/google-font-display": "warn", + "nextjs/google-font-preconnect": "warn", + "nextjs/next-script-for-ga": "warn", + "nextjs/no-async-client-component": "warn", + "nextjs/no-before-interactive-script-outside-document": "warn", + "nextjs/no-css-tags": "warn", + "nextjs/no-head-element": "warn", + "nextjs/no-html-link-for-pages": "warn", + "nextjs/no-img-element": "warn", + "nextjs/no-page-custom-font": "warn", + "nextjs/no-styled-jsx-in-document": "warn", + "nextjs/no-sync-scripts": "warn", + "nextjs/no-title-in-document-head": "warn", + "nextjs/no-typos": "warn", + "nextjs/no-unwanted-polyfillio": "warn", + "react/rules-of-hooks": "error", + "react/exhaustive-deps": "warn", + }, + globals: { + AbsoluteOrientationSensor: "readonly", + AbstractRange: "readonly", + Accelerometer: "readonly", + addEventListener: "readonly", + ai: "readonly", + AI: "readonly", + AICreateMonitor: "readonly", + AITextSession: "readonly", + alert: "readonly", + AnalyserNode: "readonly", + Animation: "readonly", + AnimationEffect: "readonly", + AnimationEvent: "readonly", + AnimationPlaybackEvent: "readonly", + AnimationTimeline: "readonly", + AsyncDisposableStack: "readonly", + Attr: "readonly", + Audio: "readonly", + AudioBuffer: "readonly", + AudioBufferSourceNode: "readonly", + AudioContext: "readonly", + AudioData: "readonly", + AudioDecoder: "readonly", + AudioDestinationNode: "readonly", + AudioEncoder: "readonly", + AudioListener: "readonly", + AudioNode: "readonly", + AudioParam: "readonly", + AudioParamMap: "readonly", + AudioProcessingEvent: "readonly", + AudioScheduledSourceNode: "readonly", + AudioSinkInfo: "readonly", + AudioWorklet: "readonly", + AudioWorkletGlobalScope: "readonly", + AudioWorkletNode: "readonly", + AudioWorkletProcessor: "readonly", + AuthenticatorAssertionResponse: "readonly", + AuthenticatorAttestationResponse: "readonly", + AuthenticatorResponse: "readonly", + BackgroundFetchManager: "readonly", + BackgroundFetchRecord: "readonly", + BackgroundFetchRegistration: "readonly", + BarcodeDetector: "readonly", + BarProp: "readonly", + BaseAudioContext: "readonly", + BatteryManager: "readonly", + BeforeUnloadEvent: "readonly", + BiquadFilterNode: "readonly", + BlobEvent: "readonly", + Bluetooth: "readonly", + BluetoothCharacteristicProperties: "readonly", + BluetoothDevice: "readonly", + BluetoothRemoteGATTCharacteristic: "readonly", + BluetoothRemoteGATTDescriptor: "readonly", + BluetoothRemoteGATTServer: "readonly", + BluetoothRemoteGATTService: "readonly", + BluetoothUUID: "readonly", + blur: "readonly", + BrowserCaptureMediaStreamTrack: "readonly", + Cache: "readonly", + caches: "readonly", + CacheStorage: "readonly", + cancelAnimationFrame: "readonly", + cancelIdleCallback: "readonly", + CanvasCaptureMediaStream: "readonly", + CanvasCaptureMediaStreamTrack: "readonly", + CanvasGradient: "readonly", + CanvasPattern: "readonly", + CanvasRenderingContext2D: "readonly", + CaptureController: "readonly", + CaretPosition: "readonly", + CDATASection: "readonly", + ChannelMergerNode: "readonly", + ChannelSplitterNode: "readonly", + ChapterInformation: "readonly", + CharacterBoundsUpdateEvent: "readonly", + CharacterData: "readonly", + clientInformation: "readonly", + Clipboard: "readonly", + ClipboardChangeEvent: "readonly", + ClipboardEvent: "readonly", + ClipboardItem: "readonly", + close: "readonly", + closed: "readonly", + CloseWatcher: "readonly", + CommandEvent: "readonly", + Comment: "readonly", + CompositionEvent: "readonly", + confirm: "readonly", + ConstantSourceNode: "readonly", + ContentVisibilityAutoStateChangeEvent: "readonly", + ConvolverNode: "readonly", + CookieChangeEvent: "readonly", + CookieDeprecationLabel: "readonly", + cookieStore: "readonly", + CookieStore: "readonly", + CookieStoreManager: "readonly", + createImageBitmap: "readonly", + CreateMonitor: "readonly", + Credential: "readonly", + credentialless: "readonly", + CredentialsContainer: "readonly", + CropTarget: "readonly", + crossOriginIsolated: "readonly", + CSPViolationReportBody: "readonly", + CSS: "readonly", + CSSAnimation: "readonly", + CSSConditionRule: "readonly", + CSSContainerRule: "readonly", + CSSCounterStyleRule: "readonly", + CSSFontFaceRule: "readonly", + CSSFontFeatureValuesRule: "readonly", + CSSFontPaletteValuesRule: "readonly", + CSSFunctionDeclarations: "readonly", + CSSFunctionDescriptors: "readonly", + CSSFunctionRule: "readonly", + CSSGroupingRule: "readonly", + CSSImageValue: "readonly", + CSSImportRule: "readonly", + CSSKeyframeRule: "readonly", + CSSKeyframesRule: "readonly", + CSSKeywordValue: "readonly", + CSSLayerBlockRule: "readonly", + CSSLayerStatementRule: "readonly", + CSSMarginRule: "readonly", + CSSMathClamp: "readonly", + CSSMathInvert: "readonly", + CSSMathMax: "readonly", + CSSMathMin: "readonly", + CSSMathNegate: "readonly", + CSSMathProduct: "readonly", + CSSMathSum: "readonly", + CSSMathValue: "readonly", + CSSMatrixComponent: "readonly", + CSSMediaRule: "readonly", + CSSNamespaceRule: "readonly", + CSSNestedDeclarations: "readonly", + CSSNumericArray: "readonly", + CSSNumericValue: "readonly", + CSSPageDescriptors: "readonly", + CSSPageRule: "readonly", + CSSPerspective: "readonly", + CSSPositionTryDescriptors: "readonly", + CSSPositionTryRule: "readonly", + CSSPositionValue: "readonly", + CSSPropertyRule: "readonly", + CSSRotate: "readonly", + CSSRule: "readonly", + CSSRuleList: "readonly", + CSSScale: "readonly", + CSSScopeRule: "readonly", + CSSSkew: "readonly", + CSSSkewX: "readonly", + CSSSkewY: "readonly", + CSSStartingStyleRule: "readonly", + CSSStyleDeclaration: "readonly", + CSSStyleRule: "readonly", + CSSStyleSheet: "readonly", + CSSStyleValue: "readonly", + CSSSupportsRule: "readonly", + CSSTransformComponent: "readonly", + CSSTransformValue: "readonly", + CSSTransition: "readonly", + CSSTranslate: "readonly", + CSSUnitValue: "readonly", + CSSUnparsedValue: "readonly", + CSSVariableReferenceValue: "readonly", + CSSViewTransitionRule: "readonly", + currentFrame: "readonly", + currentTime: "readonly", + CustomElementRegistry: "readonly", + customElements: "readonly", + CustomStateSet: "readonly", + DataTransfer: "readonly", + DataTransferItem: "readonly", + DataTransferItemList: "readonly", + DelayNode: "readonly", + DelegatedInkTrailPresenter: "readonly", + DeviceMotionEvent: "readonly", + DeviceMotionEventAcceleration: "readonly", + DeviceMotionEventRotationRate: "readonly", + DeviceOrientationEvent: "readonly", + devicePixelRatio: "readonly", + DevicePosture: "readonly", + dispatchEvent: "readonly", + DisposableStack: "readonly", + document: "readonly", + Document: "readonly", + DocumentFragment: "readonly", + documentPictureInPicture: "readonly", + DocumentPictureInPicture: "readonly", + DocumentPictureInPictureEvent: "readonly", + DocumentTimeline: "readonly", + DocumentType: "readonly", + DOMError: "readonly", + DOMImplementation: "readonly", + DOMMatrix: "readonly", + DOMMatrixReadOnly: "readonly", + DOMParser: "readonly", + DOMPoint: "readonly", + DOMPointReadOnly: "readonly", + DOMQuad: "readonly", + DOMRect: "readonly", + DOMRectList: "readonly", + DOMRectReadOnly: "readonly", + DOMStringList: "readonly", + DOMStringMap: "readonly", + DOMTokenList: "readonly", + DragEvent: "readonly", + DynamicsCompressorNode: "readonly", + EditContext: "readonly", + Element: "readonly", + ElementInternals: "readonly", + EncodedAudioChunk: "readonly", + EncodedVideoChunk: "readonly", + event: "readonly", + EventCounts: "readonly", + EventSource: "readonly", + external: "readonly", + External: "readonly", + EyeDropper: "readonly", + FeaturePolicy: "readonly", + FederatedCredential: "readonly", + fence: "readonly", + Fence: "readonly", + FencedFrameConfig: "readonly", + fetchLater: "readonly", + FetchLaterResult: "readonly", + FileList: "readonly", + FileReader: "readonly", + FileSystem: "readonly", + FileSystemDirectoryEntry: "readonly", + FileSystemDirectoryHandle: "readonly", + FileSystemDirectoryReader: "readonly", + FileSystemEntry: "readonly", + FileSystemFileEntry: "readonly", + FileSystemFileHandle: "readonly", + FileSystemHandle: "readonly", + FileSystemObserver: "readonly", + FileSystemWritableFileStream: "readonly", + find: "readonly", + focus: "readonly", + FocusEvent: "readonly", + FontData: "readonly", + FontFace: "readonly", + FontFaceSet: "readonly", + FontFaceSetLoadEvent: "readonly", + FormDataEvent: "readonly", + FragmentDirective: "readonly", + frameElement: "readonly", + frames: "readonly", + GainNode: "readonly", + Gamepad: "readonly", + GamepadAxisMoveEvent: "readonly", + GamepadButton: "readonly", + GamepadButtonEvent: "readonly", + GamepadEvent: "readonly", + GamepadHapticActuator: "readonly", + GamepadPose: "readonly", + Geolocation: "readonly", + GeolocationCoordinates: "readonly", + GeolocationPosition: "readonly", + GeolocationPositionError: "readonly", + getComputedStyle: "readonly", + getScreenDetails: "readonly", + getSelection: "readonly", + GPU: "readonly", + GPUAdapter: "readonly", + GPUAdapterInfo: "readonly", + GPUBindGroup: "readonly", + GPUBindGroupLayout: "readonly", + GPUBuffer: "readonly", + GPUBufferUsage: "readonly", + GPUCanvasContext: "readonly", + GPUColorWrite: "readonly", + GPUCommandBuffer: "readonly", + GPUCommandEncoder: "readonly", + GPUCompilationInfo: "readonly", + GPUCompilationMessage: "readonly", + GPUComputePassEncoder: "readonly", + GPUComputePipeline: "readonly", + GPUDevice: "readonly", + GPUDeviceLostInfo: "readonly", + GPUError: "readonly", + GPUExternalTexture: "readonly", + GPUInternalError: "readonly", + GPUMapMode: "readonly", + GPUOutOfMemoryError: "readonly", + GPUPipelineError: "readonly", + GPUPipelineLayout: "readonly", + GPUQuerySet: "readonly", + GPUQueue: "readonly", + GPURenderBundle: "readonly", + GPURenderBundleEncoder: "readonly", + GPURenderPassEncoder: "readonly", + GPURenderPipeline: "readonly", + GPUSampler: "readonly", + GPUShaderModule: "readonly", + GPUShaderStage: "readonly", + GPUSupportedFeatures: "readonly", + GPUSupportedLimits: "readonly", + GPUTexture: "readonly", + GPUTextureUsage: "readonly", + GPUTextureView: "readonly", + GPUUncapturedErrorEvent: "readonly", + GPUValidationError: "readonly", + GravitySensor: "readonly", + Gyroscope: "readonly", + HashChangeEvent: "readonly", + HID: "readonly", + HIDConnectionEvent: "readonly", + HIDDevice: "readonly", + HIDInputReportEvent: "readonly", + Highlight: "readonly", + HighlightRegistry: "readonly", + history: "readonly", + History: "readonly", + HTMLAllCollection: "readonly", + HTMLAnchorElement: "readonly", + HTMLAreaElement: "readonly", + HTMLAudioElement: "readonly", + HTMLBaseElement: "readonly", + HTMLBodyElement: "readonly", + HTMLBRElement: "readonly", + HTMLButtonElement: "readonly", + HTMLCanvasElement: "readonly", + HTMLCollection: "readonly", + HTMLDataElement: "readonly", + HTMLDataListElement: "readonly", + HTMLDetailsElement: "readonly", + HTMLDialogElement: "readonly", + HTMLDirectoryElement: "readonly", + HTMLDivElement: "readonly", + HTMLDListElement: "readonly", + HTMLDocument: "readonly", + HTMLElement: "readonly", + HTMLEmbedElement: "readonly", + HTMLFencedFrameElement: "readonly", + HTMLFieldSetElement: "readonly", + HTMLFontElement: "readonly", + HTMLFormControlsCollection: "readonly", + HTMLFormElement: "readonly", + HTMLFrameElement: "readonly", + HTMLFrameSetElement: "readonly", + HTMLHeadElement: "readonly", + HTMLHeadingElement: "readonly", + HTMLHRElement: "readonly", + HTMLHtmlElement: "readonly", + HTMLIFrameElement: "readonly", + HTMLImageElement: "readonly", + HTMLInputElement: "readonly", + HTMLLabelElement: "readonly", + HTMLLegendElement: "readonly", + HTMLLIElement: "readonly", + HTMLLinkElement: "readonly", + HTMLMapElement: "readonly", + HTMLMarqueeElement: "readonly", + HTMLMediaElement: "readonly", + HTMLMenuElement: "readonly", + HTMLMetaElement: "readonly", + HTMLMeterElement: "readonly", + HTMLModElement: "readonly", + HTMLObjectElement: "readonly", + HTMLOListElement: "readonly", + HTMLOptGroupElement: "readonly", + HTMLOptionElement: "readonly", + HTMLOptionsCollection: "readonly", + HTMLOutputElement: "readonly", + HTMLParagraphElement: "readonly", + HTMLParamElement: "readonly", + HTMLPictureElement: "readonly", + HTMLPreElement: "readonly", + HTMLProgressElement: "readonly", + HTMLQuoteElement: "readonly", + HTMLScriptElement: "readonly", + HTMLSelectedContentElement: "readonly", + HTMLSelectElement: "readonly", + HTMLSlotElement: "readonly", + HTMLSourceElement: "readonly", + HTMLSpanElement: "readonly", + HTMLStyleElement: "readonly", + HTMLTableCaptionElement: "readonly", + HTMLTableCellElement: "readonly", + HTMLTableColElement: "readonly", + HTMLTableElement: "readonly", + HTMLTableRowElement: "readonly", + HTMLTableSectionElement: "readonly", + HTMLTemplateElement: "readonly", + HTMLTextAreaElement: "readonly", + HTMLTimeElement: "readonly", + HTMLTitleElement: "readonly", + HTMLTrackElement: "readonly", + HTMLUListElement: "readonly", + HTMLUnknownElement: "readonly", + HTMLVideoElement: "readonly", + IDBCursor: "readonly", + IDBCursorWithValue: "readonly", + IDBDatabase: "readonly", + IDBFactory: "readonly", + IDBIndex: "readonly", + IDBKeyRange: "readonly", + IDBObjectStore: "readonly", + IDBOpenDBRequest: "readonly", + IDBRequest: "readonly", + IDBTransaction: "readonly", + IDBVersionChangeEvent: "readonly", + IdentityCredential: "readonly", + IdentityCredentialError: "readonly", + IdentityProvider: "readonly", + IdleDeadline: "readonly", + IdleDetector: "readonly", + IIRFilterNode: "readonly", + Image: "readonly", + ImageBitmap: "readonly", + ImageBitmapRenderingContext: "readonly", + ImageCapture: "readonly", + ImageData: "readonly", + ImageDecoder: "readonly", + ImageTrack: "readonly", + ImageTrackList: "readonly", + indexedDB: "readonly", + Ink: "readonly", + innerHeight: "readonly", + innerWidth: "readonly", + InputDeviceCapabilities: "readonly", + InputDeviceInfo: "readonly", + InputEvent: "readonly", + IntegrityViolationReportBody: "readonly", + IntersectionObserver: "readonly", + IntersectionObserverEntry: "readonly", + isSecureContext: "readonly", + Keyboard: "readonly", + KeyboardEvent: "readonly", + KeyboardLayoutMap: "readonly", + KeyframeEffect: "readonly", + LanguageDetector: "readonly", + LargestContentfulPaint: "readonly", + LaunchParams: "readonly", + launchQueue: "readonly", + LaunchQueue: "readonly", + LayoutShift: "readonly", + LayoutShiftAttribution: "readonly", + length: "readonly", + LinearAccelerationSensor: "readonly", + location: "writable", + Location: "readonly", + locationbar: "readonly", + Lock: "readonly", + LockManager: "readonly", + matchMedia: "readonly", + MathMLElement: "readonly", + MediaCapabilities: "readonly", + MediaCapabilitiesInfo: "readonly", + MediaDeviceInfo: "readonly", + MediaDevices: "readonly", + MediaElementAudioSourceNode: "readonly", + MediaEncryptedEvent: "readonly", + MediaError: "readonly", + MediaKeyError: "readonly", + MediaKeyMessageEvent: "readonly", + MediaKeys: "readonly", + MediaKeySession: "readonly", + MediaKeyStatusMap: "readonly", + MediaKeySystemAccess: "readonly", + MediaList: "readonly", + MediaMetadata: "readonly", + MediaQueryList: "readonly", + MediaQueryListEvent: "readonly", + MediaRecorder: "readonly", + MediaRecorderErrorEvent: "readonly", + MediaSession: "readonly", + MediaSource: "readonly", + MediaSourceHandle: "readonly", + MediaStream: "readonly", + MediaStreamAudioDestinationNode: "readonly", + MediaStreamAudioSourceNode: "readonly", + MediaStreamEvent: "readonly", + MediaStreamTrack: "readonly", + MediaStreamTrackAudioSourceNode: "readonly", + MediaStreamTrackAudioStats: "readonly", + MediaStreamTrackEvent: "readonly", + MediaStreamTrackGenerator: "readonly", + MediaStreamTrackProcessor: "readonly", + MediaStreamTrackVideoStats: "readonly", + menubar: "readonly", + MIDIAccess: "readonly", + MIDIConnectionEvent: "readonly", + MIDIInput: "readonly", + MIDIInputMap: "readonly", + MIDIMessageEvent: "readonly", + MIDIOutput: "readonly", + MIDIOutputMap: "readonly", + MIDIPort: "readonly", + MimeType: "readonly", + MimeTypeArray: "readonly", + model: "readonly", + ModelGenericSession: "readonly", + ModelManager: "readonly", + MouseEvent: "readonly", + moveBy: "readonly", + moveTo: "readonly", + MutationEvent: "readonly", + MutationObserver: "readonly", + MutationRecord: "readonly", + name: "readonly", + NamedNodeMap: "readonly", + NavigateEvent: "readonly", + navigation: "readonly", + Navigation: "readonly", + NavigationActivation: "readonly", + NavigationCurrentEntryChangeEvent: "readonly", + NavigationDestination: "readonly", + NavigationHistoryEntry: "readonly", + NavigationPreloadManager: "readonly", + NavigationTransition: "readonly", + NavigatorLogin: "readonly", + NavigatorManagedData: "readonly", + NavigatorUAData: "readonly", + NetworkInformation: "readonly", + Node: "readonly", + NodeFilter: "readonly", + NodeIterator: "readonly", + NodeList: "readonly", + Notification: "readonly", + NotifyPaintEvent: "readonly", + NotRestoredReasonDetails: "readonly", + NotRestoredReasons: "readonly", + Observable: "readonly", + OfflineAudioCompletionEvent: "readonly", + OfflineAudioContext: "readonly", + offscreenBuffering: "readonly", + OffscreenCanvas: "readonly", + OffscreenCanvasRenderingContext2D: "readonly", + onabort: "writable", + onafterprint: "writable", + onanimationcancel: "writable", + onanimationend: "writable", + onanimationiteration: "writable", + onanimationstart: "writable", + onappinstalled: "writable", + onauxclick: "writable", + onbeforeinput: "writable", + onbeforeinstallprompt: "writable", + onbeforematch: "writable", + onbeforeprint: "writable", + onbeforetoggle: "writable", + onbeforeunload: "writable", + onbeforexrselect: "writable", + onblur: "writable", + oncancel: "writable", + oncanplay: "writable", + oncanplaythrough: "writable", + onchange: "writable", + onclick: "writable", + onclose: "writable", + oncommand: "writable", + oncontentvisibilityautostatechange: "writable", + oncontextlost: "writable", + oncontextmenu: "writable", + oncontextrestored: "writable", + oncopy: "writable", + oncuechange: "writable", + oncut: "writable", + ondblclick: "writable", + ondevicemotion: "writable", + ondeviceorientation: "writable", + ondeviceorientationabsolute: "writable", + ondrag: "writable", + ondragend: "writable", + ondragenter: "writable", + ondragleave: "writable", + ondragover: "writable", + ondragstart: "writable", + ondrop: "writable", + ondurationchange: "writable", + onemptied: "writable", + onended: "writable", + onerror: "writable", + onfocus: "writable", + onformdata: "writable", + ongamepadconnected: "writable", + ongamepaddisconnected: "writable", + ongotpointercapture: "writable", + onhashchange: "writable", + oninput: "writable", + oninvalid: "writable", + onkeydown: "writable", + onkeypress: "writable", + onkeyup: "writable", + onlanguagechange: "writable", + onload: "writable", + onloadeddata: "writable", + onloadedmetadata: "writable", + onloadstart: "writable", + onlostpointercapture: "writable", + onmessage: "writable", + onmessageerror: "writable", + onmousedown: "writable", + onmouseenter: "writable", + onmouseleave: "writable", + onmousemove: "writable", + onmouseout: "writable", + onmouseover: "writable", + onmouseup: "writable", + onmousewheel: "writable", + onoffline: "writable", + ononline: "writable", + onpagehide: "writable", + onpagereveal: "writable", + onpageshow: "writable", + onpageswap: "writable", + onpaste: "writable", + onpause: "writable", + onplay: "writable", + onplaying: "writable", + onpointercancel: "writable", + onpointerdown: "writable", + onpointerenter: "writable", + onpointerleave: "writable", + onpointermove: "writable", + onpointerout: "writable", + onpointerover: "writable", + onpointerrawupdate: "writable", + onpointerup: "writable", + onpopstate: "writable", + onprogress: "writable", + onratechange: "writable", + onrejectionhandled: "writable", + onreset: "writable", + onresize: "writable", + onscroll: "writable", + onscrollend: "writable", + onscrollsnapchange: "writable", + onscrollsnapchanging: "writable", + onsearch: "writable", + onsecuritypolicyviolation: "writable", + onseeked: "writable", + onseeking: "writable", + onselect: "writable", + onselectionchange: "writable", + onselectstart: "writable", + onslotchange: "writable", + onstalled: "writable", + onstorage: "writable", + onsubmit: "writable", + onsuspend: "writable", + ontimeupdate: "writable", + ontoggle: "writable", + ontransitioncancel: "writable", + ontransitionend: "writable", + ontransitionrun: "writable", + ontransitionstart: "writable", + onunhandledrejection: "writable", + onunload: "writable", + onvolumechange: "writable", + onwaiting: "writable", + onwheel: "writable", + open: "readonly", + opener: "readonly", + Option: "readonly", + OrientationSensor: "readonly", + origin: "readonly", + originAgentCluster: "readonly", + OscillatorNode: "readonly", + OTPCredential: "readonly", + outerHeight: "readonly", + outerWidth: "readonly", + OverconstrainedError: "readonly", + PageRevealEvent: "readonly", + PageSwapEvent: "readonly", + PageTransitionEvent: "readonly", + pageXOffset: "readonly", + pageYOffset: "readonly", + PannerNode: "readonly", + parent: "readonly", + PasswordCredential: "readonly", + Path2D: "readonly", + PaymentAddress: "readonly", + PaymentManager: "readonly", + PaymentMethodChangeEvent: "readonly", + PaymentRequest: "readonly", + PaymentRequestUpdateEvent: "readonly", + PaymentResponse: "readonly", + PerformanceElementTiming: "readonly", + PerformanceEventTiming: "readonly", + PerformanceLongAnimationFrameTiming: "readonly", + PerformanceLongTaskTiming: "readonly", + PerformanceNavigation: "readonly", + PerformanceNavigationTiming: "readonly", + PerformancePaintTiming: "readonly", + PerformanceScriptTiming: "readonly", + PerformanceServerTiming: "readonly", + PerformanceTiming: "readonly", + PeriodicSyncManager: "readonly", + PeriodicWave: "readonly", + Permissions: "readonly", + PermissionStatus: "readonly", + PERSISTENT: "readonly", + personalbar: "readonly", + PictureInPictureEvent: "readonly", + PictureInPictureWindow: "readonly", + Plugin: "readonly", + PluginArray: "readonly", + PointerEvent: "readonly", + PopStateEvent: "readonly", + postMessage: "readonly", + Presentation: "readonly", + PresentationAvailability: "readonly", + PresentationConnection: "readonly", + PresentationConnectionAvailableEvent: "readonly", + PresentationConnectionCloseEvent: "readonly", + PresentationConnectionList: "readonly", + PresentationReceiver: "readonly", + PresentationRequest: "readonly", + PressureObserver: "readonly", + PressureRecord: "readonly", + print: "readonly", + ProcessingInstruction: "readonly", + Profiler: "readonly", + ProgressEvent: "readonly", + PromiseRejectionEvent: "readonly", + prompt: "readonly", + ProtectedAudience: "readonly", + PublicKeyCredential: "readonly", + PushManager: "readonly", + PushSubscription: "readonly", + PushSubscriptionOptions: "readonly", + queryLocalFonts: "readonly", + RadioNodeList: "readonly", + Range: "readonly", + registerProcessor: "readonly", + RelativeOrientationSensor: "readonly", + RemotePlayback: "readonly", + removeEventListener: "readonly", + ReportBody: "readonly", + reportError: "readonly", + ReportingObserver: "readonly", + requestAnimationFrame: "readonly", + requestIdleCallback: "readonly", + resizeBy: "readonly", + ResizeObserver: "readonly", + ResizeObserverEntry: "readonly", + ResizeObserverSize: "readonly", + resizeTo: "readonly", + RestrictionTarget: "readonly", + RTCCertificate: "readonly", + RTCDataChannel: "readonly", + RTCDataChannelEvent: "readonly", + RTCDtlsTransport: "readonly", + RTCDTMFSender: "readonly", + RTCDTMFToneChangeEvent: "readonly", + RTCEncodedAudioFrame: "readonly", + RTCEncodedVideoFrame: "readonly", + RTCError: "readonly", + RTCErrorEvent: "readonly", + RTCIceCandidate: "readonly", + RTCIceTransport: "readonly", + RTCPeerConnection: "readonly", + RTCPeerConnectionIceErrorEvent: "readonly", + RTCPeerConnectionIceEvent: "readonly", + RTCRtpReceiver: "readonly", + RTCRtpScriptTransform: "readonly", + RTCRtpSender: "readonly", + RTCRtpTransceiver: "readonly", + RTCSctpTransport: "readonly", + RTCSessionDescription: "readonly", + RTCStatsReport: "readonly", + RTCTrackEvent: "readonly", + sampleRate: "readonly", + scheduler: "readonly", + Scheduler: "readonly", + Scheduling: "readonly", + screen: "readonly", + Screen: "readonly", + ScreenDetailed: "readonly", + ScreenDetails: "readonly", + screenLeft: "readonly", + ScreenOrientation: "readonly", + screenTop: "readonly", + screenX: "readonly", + screenY: "readonly", + ScriptProcessorNode: "readonly", + scroll: "readonly", + scrollbars: "readonly", + scrollBy: "readonly", + ScrollTimeline: "readonly", + scrollTo: "readonly", + scrollX: "readonly", + scrollY: "readonly", + SecurityPolicyViolationEvent: "readonly", + Selection: "readonly", + self: "readonly", + Sensor: "readonly", + SensorErrorEvent: "readonly", + Serial: "readonly", + SerialPort: "readonly", + ServiceWorker: "readonly", + ServiceWorkerContainer: "readonly", + ServiceWorkerRegistration: "readonly", + ShadowRoot: "readonly", + sharedStorage: "readonly", + SharedStorage: "readonly", + SharedStorageAppendMethod: "readonly", + SharedStorageClearMethod: "readonly", + SharedStorageDeleteMethod: "readonly", + SharedStorageModifierMethod: "readonly", + SharedStorageSetMethod: "readonly", + SharedStorageWorklet: "readonly", + SharedWorker: "readonly", + showDirectoryPicker: "readonly", + showOpenFilePicker: "readonly", + showSaveFilePicker: "readonly", + SnapEvent: "readonly", + SourceBuffer: "readonly", + SourceBufferList: "readonly", + SpeechGrammar: "readonly", + SpeechGrammarList: "readonly", + SpeechRecognition: "readonly", + SpeechRecognitionErrorEvent: "readonly", + SpeechRecognitionEvent: "readonly", + speechSynthesis: "readonly", + SpeechSynthesis: "readonly", + SpeechSynthesisErrorEvent: "readonly", + SpeechSynthesisEvent: "readonly", + SpeechSynthesisUtterance: "readonly", + SpeechSynthesisVoice: "readonly", + StaticRange: "readonly", + status: "readonly", + statusbar: "readonly", + StereoPannerNode: "readonly", + stop: "readonly", + StorageBucket: "readonly", + StorageBucketManager: "readonly", + StorageEvent: "readonly", + StorageManager: "readonly", + styleMedia: "readonly", + StylePropertyMap: "readonly", + StylePropertyMapReadOnly: "readonly", + StyleSheet: "readonly", + StyleSheetList: "readonly", + SubmitEvent: "readonly", + Subscriber: "readonly", + Summarizer: "readonly", + SuppressedError: "readonly", + SVGAElement: "readonly", + SVGAngle: "readonly", + SVGAnimatedAngle: "readonly", + SVGAnimatedBoolean: "readonly", + SVGAnimatedEnumeration: "readonly", + SVGAnimatedInteger: "readonly", + SVGAnimatedLength: "readonly", + SVGAnimatedLengthList: "readonly", + SVGAnimatedNumber: "readonly", + SVGAnimatedNumberList: "readonly", + SVGAnimatedPreserveAspectRatio: "readonly", + SVGAnimatedRect: "readonly", + SVGAnimatedString: "readonly", + SVGAnimatedTransformList: "readonly", + SVGAnimateElement: "readonly", + SVGAnimateMotionElement: "readonly", + SVGAnimateTransformElement: "readonly", + SVGAnimationElement: "readonly", + SVGCircleElement: "readonly", + SVGClipPathElement: "readonly", + SVGComponentTransferFunctionElement: "readonly", + SVGDefsElement: "readonly", + SVGDescElement: "readonly", + SVGElement: "readonly", + SVGEllipseElement: "readonly", + SVGFEBlendElement: "readonly", + SVGFEColorMatrixElement: "readonly", + SVGFEComponentTransferElement: "readonly", + SVGFECompositeElement: "readonly", + SVGFEConvolveMatrixElement: "readonly", + SVGFEDiffuseLightingElement: "readonly", + SVGFEDisplacementMapElement: "readonly", + SVGFEDistantLightElement: "readonly", + SVGFEDropShadowElement: "readonly", + SVGFEFloodElement: "readonly", + SVGFEFuncAElement: "readonly", + SVGFEFuncBElement: "readonly", + SVGFEFuncGElement: "readonly", + SVGFEFuncRElement: "readonly", + SVGFEGaussianBlurElement: "readonly", + SVGFEImageElement: "readonly", + SVGFEMergeElement: "readonly", + SVGFEMergeNodeElement: "readonly", + SVGFEMorphologyElement: "readonly", + SVGFEOffsetElement: "readonly", + SVGFEPointLightElement: "readonly", + SVGFESpecularLightingElement: "readonly", + SVGFESpotLightElement: "readonly", + SVGFETileElement: "readonly", + SVGFETurbulenceElement: "readonly", + SVGFilterElement: "readonly", + SVGForeignObjectElement: "readonly", + SVGGElement: "readonly", + SVGGeometryElement: "readonly", + SVGGradientElement: "readonly", + SVGGraphicsElement: "readonly", + SVGImageElement: "readonly", + SVGLength: "readonly", + SVGLengthList: "readonly", + SVGLinearGradientElement: "readonly", + SVGLineElement: "readonly", + SVGMarkerElement: "readonly", + SVGMaskElement: "readonly", + SVGMatrix: "readonly", + SVGMetadataElement: "readonly", + SVGMPathElement: "readonly", + SVGNumber: "readonly", + SVGNumberList: "readonly", + SVGPathElement: "readonly", + SVGPatternElement: "readonly", + SVGPoint: "readonly", + SVGPointList: "readonly", + SVGPolygonElement: "readonly", + SVGPolylineElement: "readonly", + SVGPreserveAspectRatio: "readonly", + SVGRadialGradientElement: "readonly", + SVGRect: "readonly", + SVGRectElement: "readonly", + SVGScriptElement: "readonly", + SVGSetElement: "readonly", + SVGStopElement: "readonly", + SVGStringList: "readonly", + SVGStyleElement: "readonly", + SVGSVGElement: "readonly", + SVGSwitchElement: "readonly", + SVGSymbolElement: "readonly", + SVGTextContentElement: "readonly", + SVGTextElement: "readonly", + SVGTextPathElement: "readonly", + SVGTextPositioningElement: "readonly", + SVGTitleElement: "readonly", + SVGTransform: "readonly", + SVGTransformList: "readonly", + SVGTSpanElement: "readonly", + SVGUnitTypes: "readonly", + SVGUseElement: "readonly", + SVGViewElement: "readonly", + SyncManager: "readonly", + TaskAttributionTiming: "readonly", + TaskController: "readonly", + TaskPriorityChangeEvent: "readonly", + TaskSignal: "readonly", + TEMPORARY: "readonly", + Text: "readonly", + TextEvent: "readonly", + TextFormat: "readonly", + TextFormatUpdateEvent: "readonly", + TextMetrics: "readonly", + TextTrack: "readonly", + TextTrackCue: "readonly", + TextTrackCueList: "readonly", + TextTrackList: "readonly", + TextUpdateEvent: "readonly", + TimeEvent: "readonly", + TimeRanges: "readonly", + ToggleEvent: "readonly", + toolbar: "readonly", + top: "readonly", + Touch: "readonly", + TouchEvent: "readonly", + TouchList: "readonly", + TrackEvent: "readonly", + TransitionEvent: "readonly", + Translator: "readonly", + TreeWalker: "readonly", + TrustedHTML: "readonly", + TrustedScript: "readonly", + TrustedScriptURL: "readonly", + TrustedTypePolicy: "readonly", + TrustedTypePolicyFactory: "readonly", + trustedTypes: "readonly", + UIEvent: "readonly", + USB: "readonly", + USBAlternateInterface: "readonly", + USBConfiguration: "readonly", + USBConnectionEvent: "readonly", + USBDevice: "readonly", + USBEndpoint: "readonly", + USBInterface: "readonly", + USBInTransferResult: "readonly", + USBIsochronousInTransferPacket: "readonly", + USBIsochronousInTransferResult: "readonly", + USBIsochronousOutTransferPacket: "readonly", + USBIsochronousOutTransferResult: "readonly", + USBOutTransferResult: "readonly", + UserActivation: "readonly", + ValidityState: "readonly", + VideoColorSpace: "readonly", + VideoDecoder: "readonly", + VideoEncoder: "readonly", + VideoFrame: "readonly", + VideoPlaybackQuality: "readonly", + viewport: "readonly", + Viewport: "readonly", + ViewTimeline: "readonly", + ViewTransition: "readonly", + ViewTransitionTypeSet: "readonly", + VirtualKeyboard: "readonly", + VirtualKeyboardGeometryChangeEvent: "readonly", + VisibilityStateEntry: "readonly", + visualViewport: "readonly", + VisualViewport: "readonly", + VTTCue: "readonly", + VTTRegion: "readonly", + WakeLock: "readonly", + WakeLockSentinel: "readonly", + WaveShaperNode: "readonly", + WebGL2RenderingContext: "readonly", + WebGLActiveInfo: "readonly", + WebGLBuffer: "readonly", + WebGLContextEvent: "readonly", + WebGLFramebuffer: "readonly", + WebGLObject: "readonly", + WebGLProgram: "readonly", + WebGLQuery: "readonly", + WebGLRenderbuffer: "readonly", + WebGLRenderingContext: "readonly", + WebGLSampler: "readonly", + WebGLShader: "readonly", + WebGLShaderPrecisionFormat: "readonly", + WebGLSync: "readonly", + WebGLTexture: "readonly", + WebGLTransformFeedback: "readonly", + WebGLUniformLocation: "readonly", + WebGLVertexArrayObject: "readonly", + WebSocketError: "readonly", + WebSocketStream: "readonly", + WebTransport: "readonly", + WebTransportBidirectionalStream: "readonly", + WebTransportDatagramDuplexStream: "readonly", + WebTransportError: "readonly", + WebTransportReceiveStream: "readonly", + WebTransportSendStream: "readonly", + WGSLLanguageFeatures: "readonly", + WheelEvent: "readonly", + when: "readonly", + window: "readonly", + Window: "readonly", + WindowControlsOverlay: "readonly", + WindowControlsOverlayGeometryChangeEvent: "readonly", + Worker: "readonly", + Worklet: "readonly", + WorkletGlobalScope: "readonly", + XMLDocument: "readonly", + XMLHttpRequest: "readonly", + XMLHttpRequestEventTarget: "readonly", + XMLHttpRequestUpload: "readonly", + XMLSerializer: "readonly", + XPathEvaluator: "readonly", + XPathExpression: "readonly", + XPathResult: "readonly", + XRAnchor: "readonly", + XRAnchorSet: "readonly", + XRBoundedReferenceSpace: "readonly", + XRCamera: "readonly", + XRCPUDepthInformation: "readonly", + XRDepthInformation: "readonly", + XRDOMOverlayState: "readonly", + XRFrame: "readonly", + XRHand: "readonly", + XRHitTestResult: "readonly", + XRHitTestSource: "readonly", + XRInputSource: "readonly", + XRInputSourceArray: "readonly", + XRInputSourceEvent: "readonly", + XRInputSourcesChangeEvent: "readonly", + XRJointPose: "readonly", + XRJointSpace: "readonly", + XRLayer: "readonly", + XRLightEstimate: "readonly", + XRLightProbe: "readonly", + XRPose: "readonly", + XRRay: "readonly", + XRReferenceSpace: "readonly", + XRReferenceSpaceEvent: "readonly", + XRRenderState: "readonly", + XRRigidTransform: "readonly", + XRSession: "readonly", + XRSessionEvent: "readonly", + XRSpace: "readonly", + XRSystem: "readonly", + XRTransientInputHitTestResult: "readonly", + XRTransientInputHitTestSource: "readonly", + XRView: "readonly", + XRViewerPose: "readonly", + XRViewport: "readonly", + XRWebGLBinding: "readonly", + XRWebGLDepthInformation: "readonly", + XRWebGLLayer: "readonly", + XSLTProcessor: "readonly", + }, + plugins: ["import", "jsx-a11y"], + env: { + node: true, + }, + }, + { + files: ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], + rules: { + "constructor-super": "off", + "getter-return": "off", + "no-class-assign": "off", + "no-const-assign": "off", + "no-dupe-class-members": "off", + "no-dupe-keys": "off", + "no-func-assign": "off", + "no-import-assign": "off", + "no-new-native-nonconstructor": "off", + "no-obj-calls": "off", + "no-redeclare": "off", + "no-setter-return": "off", + "no-this-before-super": "off", + "no-undef": "off", + "no-unreachable": "off", + "no-unsafe-negation": "off", + "no-var": "error", + "no-with": "off", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + }, + }, + ], + options: { + typeAware: false, + typeCheck: false, + }, + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7609e4c..b9f00c8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,17 @@ -import react from '@vitejs/plugin-react'; -import path from 'path'; -import { defineConfig } from 'vitest/config'; +import react from "@vitejs/plugin-react"; +import path from "path"; +import { defineConfig } from "vite-plus"; export default defineConfig({ plugins: [react()], test: { - environment: 'jsdom', + environment: "jsdom", globals: true, - include: ['test/**/*.test.{ts,tsx}'], - setupFiles: ['./test/setup.ts'], + include: ["test/**/*.test.{ts,tsx}"], + setupFiles: ["./test/setup.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'html'], + provider: "v8", + reporter: ["text", "html"], thresholds: { lines: 55, functions: 45, @@ -20,7 +20,7 @@ export default defineConfig({ }, }, alias: { - '@': path.resolve(__dirname, './src'), + "@": path.resolve(__dirname, "./src"), }, }, });