diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index 776a655..0000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -gsd_state_version: 1.0 -milestone: v1.0 -milestone_name: Phases -status: planning -stopped_at: Completed 12-ci-setup-01-PLAN.md -last_updated: "2026-04-02T22:36:32.982Z" -last_activity: "2026-04-03 - Completed quick task 11: Consolidate CI workflows with explicit pnpm setup" -progress: - total_phases: 12 - completed_phases: 11 - total_plans: 26 - completed_plans: 26 ---- - -# Project State - -## Project Reference - -See: .planning/PROJECT.md (updated 2026-04-02) - -**Core value:** Every command in the existing Qwik CLI must work identically in the new package — 67 MUST PRESERVE behaviors cannot regress. -**Current focus:** Milestone v1.1 — Course Correction & Completeness - -## Current Position - -Phase: Phase 7 (Type Baseline) — ready to start -Plan: — -Status: Roadmap defined; ready for planning -Last activity: 2026-04-02 - Completed quick task 7: Derive stub priority from directory, make optional - -**v1.1 Progress bar:** [----------] 0% (0/5 phases) - -## Performance Metrics - -**Velocity:** -- Total plans completed: 0 (v1.1) -- Average duration: — -- Total execution time: 0 hours - -**By Phase (v1.1):** - -| Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| 7. Type Baseline | TBD | - | - | -| 8. Content Population | TBD | - | - | -| 9. Migration Architecture | TBD | - | - | -| 10. Tooling Switch | TBD | - | - | -| 11. create-qwik Implementation | TBD | - | - | - -**Recent Trend:** -- Last 5 plans: — -- Trend: — - -*Updated after each plan completion* - -**v1.0 historical velocity (reference):** -| Phase 01-scaffold-and-core-architecture P01 | 15 | 2 tasks | 13 files | -| Phase 01-scaffold-and-core-architecture P02 | 5 | 2 tasks | 9 files | -| Phase 01-scaffold-and-core-architecture P03 | 8 | 2 tasks | 13 files | -| Phase 02-test-harness P01 | 2 | 2 tasks | 21 files | -| Phase 02-test-harness P02 | 5 | 2 tasks | 4 files | -| Phase 02-test-harness P03 | 15 | 2 tasks | 4 files | -| Phase 02-test-harness P04 | 8 | 1 task | 2 files | -| Phase 03-shared-foundations-and-simple-commands P01 | 2 | 2 tasks | 3 files | -| Phase 03-shared-foundations-and-simple-commands P02 | 10 | 2 tasks | 3 files | -| Phase 04-build-and-new-commands P01 | 12 | 1 tasks | 1 files | -| Phase 04-build-and-new-commands P02 | 12 | 2 tasks | 7 files | -| Phase 04-build-and-new-commands P03 | 12 | 1 tasks | 1 files | -| Phase 05-add-and-upgrade-commands P01 | 8 | 2 tasks | 6 files | -| Phase 05-add-and-upgrade-commands P03 | 15 | 2 tasks | 4 files | -| Phase 05-add-and-upgrade-commands P02 | 15 | 2 tasks | 3 files | -| Phase 07-type-baseline-regex-cleanup P01 | 4 | 2 tasks | 7 files | -| Phase 07-type-baseline-regex-cleanup P02 | 18 | 2 tasks | 7 files | -| Phase 08-content-population P01 | 6 | 2 tasks | 268 files | -| Phase 08-content-population P02 | 8 | 1 tasks | 1 files | -| Phase 09-migration-architecture P01 | 25 | 2 tasks | 19 files | -| Phase 09-migration-architecture P02 | 14 | 2 tasks | 3 files | -| Phase 10-tooling-switch P01 | 15 | 2 tasks | 126 files | -| Phase 11-create-qwik-implementation P01 | 13 | 2 tasks | 15 files | -| Phase 11-create-qwik-implementation P02 | 7 | 1 tasks | 2 files | -| Phase 12-ci-setup P01 | 1 | 2 tasks | 8 files | - -## Accumulated Context - -### Decisions - -Decisions are logged in PROJECT.md Key Decisions table. -Recent decisions affecting current work: - -- Init: oxc-parser + magic-string over ts-morph (lighter, matches reference impl) -- Init: Standalone repo (not monorepo extraction) for clean break and own release cycle -- Init: Japa over Vitest/Jest (matches reference implementation) -- Init: stubs/ for templates (solves __dirname extraction blocker) -- Init: Spec-first, tests-before-impl (13 spec docs + 25 golden-path scenarios define behavior before code) -- [Phase 01-scaffold-and-core-architecture]: Biome schema updated to v2.4.10 — organizeImports moved to assist.actions.source in Biome v2.4 -- [Phase 01-scaffold-and-core-architecture]: tsdown entry limited to src/index.ts only — router.ts added when created in plan 03 (missing entry causes build failure not warning) -- [Phase 01-scaffold-and-core-architecture]: tsconfig.json rootDir set to . to allow bin/ TypeScript files to compile without rootDir constraint errors -- [Phase 01-scaffold-and-core-architecture]: Program.isIt() is protected, not public — test subclass exposes isItPublic() for assertion access -- [Phase 01-scaffold-and-core-architecture]: registerCommand/registerOption/registerAlias pattern accumulates yargs config in base class, applied all at once in parse() — avoids singleton yargs pattern removed in v18 -- [Phase 01-scaffold-and-core-architecture]: HelpProgram overrides parse() returning empty args to prevent yargs from intercepting the 'help' keyword as a built-in flag -- [Phase 01-scaffold-and-core-architecture]: tsdown entry updated to include src/router.ts and bin/qwik.ts — deferred from plan 01 as planned to avoid missing-entry build failures -- [Phase 02-test-harness]: fx-02/fx-03 dist/.gitkeep omitted — fixture .gitignore correctly ignores dist/ (realistic for v1 projects) -- [Phase 02-test-harness]: Root .gitignore negation added for tests/fixtures/fx-06/dist/ — q-manifest.json must be tracked for mtime tests (CHK-02/CHK-03) -- [Phase 02-test-harness]: BUILD-04 injects failing build.server via writeFileSync in setup — avoids dedicated failing fixture, keeps setup self-contained -- [Phase 02-test-harness]: NEW-04 asserts stdout+stderr concatenated for 'already exists' — implementation may write to either stream -- [Phase 02-test-harness]: runCli/runCreateQwik use absolute TSX_ESM path — Node.js ESM --import loader resolution not affected by NODE_PATH; absolute path required when cwd is outside project root -- [Phase 02-test-harness]: MIG-01/MIG-04 have positive assertions (files MUST contain new imports) to guarantee genuine red state against stubs; MIG-02/03/05 are vacuous passes with documented TODO Phase 5 comments -- [Phase 02-test-harness]: ADD-02 positive assertion targets sub/adapters/cloudflare-pages/vite.config.ts — matches --projectDir=./sub invocation pattern established in setup -- [Phase 03-shared-foundations-and-simple-commands]: QWIK_VERSION ambient declaration must be in a separate globals.d.ts — types.ts has exports making it a module, so declare const there was module-scoped not globally visible -- [Phase 03-shared-foundations-and-simple-commands]: Joke data lives in src/commands/joke/jokes.ts as static array — no cross-package import satisfies SIMP-04 -- [Phase 03-shared-foundations-and-simple-commands]: Plain console.log for joke setup and punchline — avoids clack box-drawing characters under NO_COLOR -- [Phase 04-build-and-new-commands]: process.exitCode=1 used in parallel phase so sibling scripts are not aborted — process.exit(1) would kill siblings -- [Phase 04-build-and-new-commands]: execute() returns typeof exitCode === number ? exitCode : 0 — router calls process.exit(code), so we must propagate exitCode via return value -- [Phase 04-build-and-new-commands]: parseInputName splits on [-_\s] only; / is NOT a separator -- [Phase 04-build-and-new-commands]: getOutDir returns flat src/components for component type (no subdirectory, matches NEW-02) -- [Phase 04-build-and-new-commands]: writeTemplateFile duplicate guard throws with exact format: outFilename already exists in outDir -- [Phase 04-build-and-new-commands]: Markdown/mdx handled as special case in execute(): outDir = dirname, filename = basename+ext — produces flat src/routes/blog/post.md not subdirectory/index.md -- [Phase 05-add-and-upgrade-commands]: visitNotIgnoredFiles always adds .git to ignore rules even without .gitignore (safety, per UPGR-06 research pitfall 5) -- [Phase 05-add-and-upgrade-commands]: .ts removed from BINARY_EXTENSIONS — conflated TypeScript source with MPEG-TS video container format -- [Phase 05-add-and-upgrade-commands]: Symlinks intentionally not followed in visitNotIgnoredFiles (OQ-07 deferred decision: skip is safer default) -- [Phase 05-add-and-upgrade-commands]: replaceImportInFiles: overwrites imported identifier always; overwrites local binding only when unaliased (local.name === importedName) — prevents breaking aliased imports -- [Phase 05-add-and-upgrade-commands]: exact parameter in replacePackage is documentation marker only — both paths produce identical regex; retained to signal intent for @qwik-city-plan replacement -- [Phase 05-add-and-upgrade-commands]: Adaptive STUBS_DIR resolution (2-level for src/, 3-level for dist/) — tsx runs source files so import.meta.url resolves to src/integrations/ not dist/src/integrations/ -- [Phase 05-add-and-upgrade-commands]: skipConfirmation registered as type 'string' and compared against exact 'true' — yargs parses --flag=true as string when option type is string -- [Phase 05-add-and-upgrade-commands]: scanBoolean called in execute() not gated by isIt() — enables stdin-piping for test-driven confirm/cancel in non-TTY environments -- [Phase 05-add-and-upgrade-commands]: Cancel path uses Ctrl+C (\x03) piped to stdin — @clack/prompts isCancel() returns true for SIGINT; EOF does NOT trigger cancel (hangs with exit 13) -- [Phase 05-add-and-upgrade-commands]: process.chdir/restore wraps visitNotIgnoredFiles and runAllPackageReplacements — both use process.cwd() internally for path resolution and gitignore loading -- [Phase 05-add-and-upgrade-commands]: upgrade alias in router.ts points to same import as migrate-v2 — single source of truth, both commands always in sync -- [Phase 07-type-baseline-regex-cleanup]: Non-null assertion used for COMMANDS.help! and COMMANDS[task]! — keys are statically defined in Record literal -- [Phase 07-type-baseline-regex-cleanup]: getModuleExportName() discriminates on node.type === 'Literal' for oxc-parser ModuleExportName union -- [Phase 07-type-baseline-regex-cleanup]: Option from @clack/prompts imported as ClackOption in core.ts to avoid collision with local Option type for yargs config -- [Phase 07-type-baseline-regex-cleanup]: magic-regexp: exactly('').at.lineEnd() produces bare dollar anchor; at is a method on expression objects, not a standalone import -- [Phase 07-type-baseline-regex-cleanup]: magic-regexp 0.11.0 exports char (not anyChar) for any-character matching; charIn('-_').or(whitespace) for character class unions -- [Phase 07-type-baseline-regex-cleanup]: Export SLUG_TOKEN and NAME_TOKEN from templates.ts; new/index.ts imports them to avoid duplication -- [Phase 08-content-population]: source from build/v2 branch (not main) — csr feature exists only on build/v2 -- [Phase 08-content-population]: cloudflare-pages overwritten with upstream for consistency even though it already existed -- [Phase 08-content-population]: jokes.json lives in packages/create-qwik/src/helpers/ on main branch — build/v2 URL returned 404; adapters/ was untracked so rm -rf sufficient without git rm -- [Phase 09-migration-architecture]: buildMigrationChain filters by both fromVersion > step.version AND step.version <= toVersion to prevent out-of-range migration steps -- [Phase 09-migration-architecture]: updateDependencies called unconditionally when deps are behind — not gated by migration chain execution (MIGR-02) -- [Phase 09-migration-architecture]: migrations/ added to tsconfig.json include array — necessary for tsc to resolve types across relative import boundary -- [Phase 09-migration-architecture]: vitest.config.ts scoped to tests/unit/upgrade/ only — avoids Japa/Vitest collision on existing spec files -- [Phase 09-migration-architecture]: buildMigrationChain coerces toVersion: semver.lte('2.0.0', '2.0.0-beta.30') === false; must coerce pre-release target before upper-bound check -- [Phase 09-migration-architecture]: bin/test.ts excludes tests/unit/upgrade/** from Japa — Vitest describe/expect crashes Japa loader at file load -- [Phase 10-tooling-switch]: stubs/** and specs/** added to lint ignorePatterns — template/doc dirs not in Biome's original scope -- [Phase 10-tooling-switch]: eslint-disable-next-line for QWIK_VERSION ambient declare (build-time inject EB-05) and v3Run test variable (intentional unused) -- [Phase 10-tooling-switch]: Pre-existing Japa failures (7/75) confirmed unchanged before and after vite-plus switch — ADD-02, CHK-01, CRE-02/03 deferred to future phases -- [Phase 11-create-qwik-implementation]: Library path is self-contained (baseApp = libraryStarter, no starterApp): library never layers on top of base -- [Phase 11-create-qwik-implementation]: assert.property() replaced with assert.isDefined() in CRE-01 — chai deep-path notation misinterprets dot in @qwik.dev/core as nested path separator -- [Phase 11-create-qwik-implementation]: stubs/apps/empty: @qwik.dev/core added to dependencies (not devDependencies) — CRE-01 checks runtime deps -- [Phase 11-create-qwik-implementation]: panam/executor sub-path import (not panam/dist/executor.js) — exports map resolves correctly with NodeNext moduleResolution -- [Phase 11-create-qwik-implementation]: bgInstall tracked as outer-scoped let var so try/catch error handler can abort without per-prompt references -- [Phase 11-create-qwik-implementation]: Spinner polls bgInstall.success every 100ms during joke wait — avoids exposing proc.result to interactive layer -- [Phase 12-ci-setup]: setup-vp@v1 single step replaces manual pnpm/action-setup + setup-node + cache; Node 24 explicit; cancel-in-progress for PRs only via event_name expression - -### Roadmap Evolution - -- Phase 12 added: CI setup - -### Pending Todos - -None. - -### Blockers/Concerns - -- Phase 11 (create-qwik): Background install abort pattern with cross-spawn vs execa needs validation before implementation — research during Phase 11 planning -- Phase 11 (create-qwik): Runtime version injection approach for starter package.json dep versions needs a confirmed approach — two candidates documented in SUMMARY.md -- Phase 10 (tooling): oxlint rule coverage gap vs Biome needs audit before switch — document any rules with no oxlint equivalent -- Phase 6 (v1.0): create-qwik Runtime version injection approach for starters not confirmed in ESM context (EB-05) — needs validation during Phase 6 planning - -### Quick Tasks Completed - -| # | Description | Date | Commit | Directory | -|---|-------------|------|--------|-----------| -| 2 | Deep research on dependency cleanup: magic-regexp removal, cross-spawn to native node, argument parsing consolidation | 2026-04-02 | e1c7d41 | [2-deep-research-on-dependency-cleanup-magi](./quick/2-deep-research-on-dependency-cleanup-magi/) | -| 3 | Remove cross-spawn, replace with native node:child_process | 2026-04-02 | e537aea | [3-remove-cross-spawn-replace-with-native-n](./quick/3-remove-cross-spawn-replace-with-native-n/) | -| 5 | Upgrade TypeScript to v6, fix tsconfig for TS6 breaking changes | 2026-04-02 | 71f5541 | [5-upgrade-typescript-to-v6-and-fix-tsconfi](./quick/5-upgrade-typescript-to-v6-and-fix-tsconfi/) | -| 6 | Rewrite README with practical contributor guide for stubs | 2026-04-02 | f55d0fc | [6-rewrite-readme-with-practical-contributo](./quick/6-rewrite-readme-with-practical-contributo/) | -| 7 | Derive stub priority from directory, make optional | 2026-04-02 | 1c5fe93 | [7-derive-stub-priority-from-directory-inst](./quick/7-derive-stub-priority-from-directory-inst/) | -| 8 | Add cross-platform CI matrix with OS, runtime, and package manager dimensions | 2026-04-02 | 7580d26 | [8-add-cross-platform-ci-matrix-with-os-and](./quick/8-add-cross-platform-ci-matrix-with-os-and/) | -| 9 | Fix critical bugs and security issues (3 Codex scan rounds) | 2026-04-02 | 81ca968 | [9-fix-critical-bugs-and-security-issues-fo](./quick/9-fix-critical-bugs-and-security-issues-fo/) | -| 11 | Consolidate CI workflows with explicit pnpm setup | 2026-04-03 | 2d2c068 | [11-set-up-ci-github-actions-workflow](./quick/11-set-up-ci-github-actions-workflow/) | - -## Session Continuity - -Last session: 2026-04-03T00:47:49.000Z -Stopped at: Completed quick task 11: Consolidate CI workflows with explicit pnpm setup -Resume file: None diff --git a/.planning/quick/11-set-up-ci-github-actions-workflow/11-SUMMARY.md b/.planning/quick/11-set-up-ci-github-actions-workflow/11-SUMMARY.md deleted file mode 100644 index 7d6a68a..0000000 --- a/.planning/quick/11-set-up-ci-github-actions-workflow/11-SUMMARY.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -phase: quick -plan: 11 -subsystem: ci -tags: [ci, github-actions, pnpm, workflow] -dependency_graph: - requires: [] - provides: [working-ci-pipeline] - affects: [.github/workflows/ci.yml] -tech_stack: - added: [pnpm/action-setup@v4, actions/setup-node@v3] - patterns: [explicit-pnpm-setup, frozen-lockfile, pnpm-store-cache] -key_files: - created: [] - modified: [.github/workflows/ci.yml] - deleted: [.github/workflows/cli.yml] -decisions: - - "Dropped multi-runtime matrix (node/deno/bun x pnpm/yarn) in favor of pnpm-only OS matrix" - - "Replaced setup-js and setup-vp with explicit pnpm/action-setup@v4 + actions/setup-node@v3" - - "All CI commands run via pnpm run/exec instead of vp CLI directly" -metrics: - duration_minutes: 1 - completed: "2026-04-03T00:47:49Z" - tasks_completed: 1 - tasks_total: 1 - files_changed: 2 ---- - -# Quick Task 11: Set Up CI GitHub Actions Workflow Summary - -Consolidated two broken CI workflow files into a single working ci.yml with explicit pnpm/action-setup, pnpm store caching, and frozen-lockfile install across ubuntu/macos/windows. - -## What Was Done - -### Task 1: Delete redundant cli.yml and rewrite ci.yml with explicit pnpm setup -**Commit:** 2d2c068 - -Deleted `.github/workflows/cli.yml` (redundant workflow with emoji-laden steps and broken setup-js/setup-vp actions). Rewrote `.github/workflows/ci.yml` with: - -- `pnpm/action-setup@v4` for reliable pnpm binary on PATH -- `actions/setup-node@v3` with `cache: pnpm` for automatic pnpm store caching -- `pnpm install --frozen-lockfile` for reproducible installs -- OS matrix (ubuntu-latest, macos-latest, windows-latest) without runtime/pm dimensions -- All steps via `pnpm run` / `pnpm exec`: format:check, lint, tsc --noEmit, build, test, test:unit -- Concurrency group with cancel-in-progress for PRs - -**Key changes from old workflow:** -- Removed `siguici/setup-js@v1` (unreliable pnpm provisioning) -- Removed `voidzero-dev/setup-vp@v1` (vp accessed via pnpm run scripts instead) -- Removed `npm i -g panam-cli` (panam is a local dependency, accessed via pnpm run test) -- Removed `rm pnpm-lock.yaml` step (lockfile now used with --frozen-lockfile) -- Dropped 27-combination matrix (3 OS x 3 runtime x 3 pm) down to 3 (3 OS only) - -## Deviations from Plan - -None - plan executed exactly as written. - -## Verification Results - -- ci.yml contains `pnpm/action-setup`: confirmed (1 match) -- cli.yml deleted: confirmed (file does not exist) -- `pnpm install --frozen-lockfile` present: confirmed (1 match) -- No references to setup-js or setup-vp: confirmed (0 matches) -- YAML syntax valid: confirmed (python3 yaml.safe_load passes) - -## Self-Check: PASSED - -- .github/workflows/ci.yml: FOUND -- .github/workflows/cli.yml: CONFIRMED DELETED -- Commit 2d2c068: FOUND diff --git a/migrations/v2/apply-transforms.ts b/migrations/v2/apply-transforms.ts new file mode 100644 index 0000000..79eec98 --- /dev/null +++ b/migrations/v2/apply-transforms.ts @@ -0,0 +1,72 @@ +import MagicString from "magic-string"; +import { parseSync } from "oxc-parser"; +import { readFileSync, writeFileSync } from "node:fs"; +import type { SourceReplacement, TransformFn } from "./types.ts"; + +/** + * Parse-once, fan-out orchestrator for AST-based source transforms. + * + * Algorithm: + * 1. Early return if no transforms provided + * 2. Read source from disk once + * 3. Parse once with oxc-parser; share ParseResult across all transforms + * 4. Fan out: collect SourceReplacement[] from each transform into a flat list + * 5. Early return if no replacements collected (file unchanged) + * 6. Sort replacements descending by `start` (later offsets first) to prevent + * MagicString offset corruption when applying earlier edits + * 7. Apply all replacements via a single MagicString instance + * 8. Write back to disk only if content changed + * + * @param filePath - Absolute path to the file to transform + * @param transforms - Array of transform functions to apply + */ +export function applyTransforms(filePath: string, transforms: TransformFn[]): void { + // Step 1: Early return for empty transform list + if (transforms.length === 0) return; + + // Step 2: Read source once + const source = readFileSync(filePath, "utf-8"); + + // Step 3: Parse once + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + + // Step 4: Fan out — collect all replacements + const allReplacements: SourceReplacement[] = []; + for (const transform of transforms) { + const replacements = transform(filePath, source, parseResult); + allReplacements.push(...replacements); + } + + // Step 5: Early return if nothing to replace + if (allReplacements.length === 0) return; + + // Step 6: Sort descending by start so later offsets are applied first + allReplacements.sort((a, b) => b.start - a.start); + + // Step 6b: Detect overlapping replacements before applying (magic-string does not + // always throw a useful error; we surface a descriptive one instead). + // After descending sort, replacement[i].start >= replacement[i+1].start. + // A collision occurs when replacement[i+1].end > replacement[i].start. + for (let i = 0; i < allReplacements.length - 1; i++) { + const curr = allReplacements[i]!; + const next = allReplacements[i + 1]!; + if (next.end > curr.start) { + throw new Error( + `applyTransforms: overlapping replacements detected in "${filePath}". ` + + `Replacement at [${next.start}, ${next.end}) overlaps with [${curr.start}, ${curr.end}). ` + + `Each transform must produce non-overlapping SourceReplacement ranges.`, + ); + } + } + + // Step 7: Apply via single MagicString instance + const ms = new MagicString(source); + for (const { start, end, replacement } of allReplacements) { + ms.overwrite(start, end, replacement); + } + + // Step 8: Write back only when content changed + if (ms.hasChanged()) { + writeFileSync(filePath, ms.toString(), "utf-8"); + } +} diff --git a/migrations/v2/binary-extensions.ts b/migrations/v2/binary-extensions.ts index 8c05c23..7f766e3 100644 --- a/migrations/v2/binary-extensions.ts +++ b/migrations/v2/binary-extensions.ts @@ -1,8 +1,9 @@ import { extname } from "node:path"; /** - * Set of known binary file extensions (lowercased, including the dot). - * Based on the sindresorhus/binary-extensions list. + * Pruned set of binary file extensions relevant to Qwik projects (lowercased, including the dot). + * Contains ~50 essential entries covering images, fonts, archives, executables, audio, video, and + * other common binary formats. Excludes niche formats unlikely to appear in a Qwik project. */ export const BINARY_EXTENSIONS: Set = new Set([ // Images @@ -16,42 +17,17 @@ export const BINARY_EXTENSIONS: Set = new Set([ ".svg", ".tiff", ".tif", - ".psd", - ".ai", - ".eps", - ".raw", - ".cr2", - ".nef", - ".orf", - ".sr2", ".avif", ".heic", ".heif", - ".jxl", ".apng", - ".cur", - ".ani", - ".jfif", - ".jp2", - ".j2k", - ".jpf", - ".jpx", - ".jpm", - // Documents - ".pdf", - ".doc", - ".docx", - ".xls", - ".xlsx", - ".ppt", - ".pptx", - ".odt", - ".ods", - ".odp", - ".pages", - ".numbers", - ".key", + // Fonts + ".woff", + ".woff2", + ".ttf", + ".eot", + ".otf", // Archives ".zip", @@ -61,56 +37,16 @@ export const BINARY_EXTENSIONS: Set = new Set([ ".7z", ".bz2", ".xz", - ".lz", - ".lzma", - ".z", ".tgz", - ".tbz", - ".tbz2", - ".txz", - ".tlz", - ".cab", - ".deb", - ".rpm", - ".apk", - ".ipa", - ".crx", - ".iso", - ".img", - ".dmg", - ".pkg", - ".msi", // Executables and binaries ".exe", ".dll", ".so", ".dylib", - ".lib", ".a", ".o", - ".obj", - ".pdb", - ".com", - ".bat", - ".cmd", - ".scr", - ".msc", ".bin", - ".elf", - ".out", - ".app", - - // Fonts - ".woff", - ".woff2", - ".ttf", - ".eot", - ".otf", - ".fon", - ".fnt", - ".pfb", - ".pfm", // Audio ".mp3", @@ -119,122 +55,24 @@ export const BINARY_EXTENSIONS: Set = new Set([ ".flac", ".aac", ".m4a", - ".wma", - ".aiff", - ".aif", - ".au", ".opus", - ".mid", - ".midi", - ".ra", - ".ram", - ".amr", // Video ".mp4", ".avi", ".mov", ".mkv", - ".wmv", - ".flv", ".webm", ".m4v", - ".3gp", - ".3g2", - ".ogv", - ".mts", - ".m2ts", - ".vob", - ".mpg", - ".mpeg", - ".m2v", - ".m4p", - ".m4b", - ".m4r", - ".f4v", - ".f4a", - ".f4b", - ".f4p", - ".swf", - ".asf", - ".rm", - ".rmvb", - ".divx", - - // Java / compiled bytecode - ".class", - ".jar", - ".war", - ".ear", - - // Python compiled - ".pyc", - ".pyo", - ".pyd", // WebAssembly ".wasm", - // Databases / data stores + // Documents / data + ".pdf", ".sqlite", - ".sqlite3", ".db", - ".db3", - ".s3db", - ".sl3", - ".mdb", - ".accdb", - - // 3D / game assets - ".blend", - ".fbx", - ".obj", - ".dae", - ".3ds", - ".max", - ".ma", - ".mb", - ".stl", - ".glb", - ".gltf", - ".nif", - ".bsa", - ".pak", - ".unity", - ".unitypackage", - - // Flash - ".swf", - ".fla", - - // Disk images - ".vmdk", - ".vhd", - ".vdi", - ".qcow2", - - // Certificates / keys - ".der", - ".cer", - ".crt", - ".p12", - ".pfx", - ".p7b", - - // Other binary formats - ".nupkg", - ".snupkg", - ".rdb", - ".ldb", - ".lnk", - ".DS_Store", ".plist", - ".xib", - ".nib", - ".icns", - ".dSYM", - ".map", - ".min", ]); /** diff --git a/migrations/v2/fix-config.ts b/migrations/v2/fix-config.ts new file mode 100644 index 0000000..a54bcf2 --- /dev/null +++ b/migrations/v2/fix-config.ts @@ -0,0 +1,86 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +/** + * CONF-01: Rewrite jsxImportSource in tsconfig.json from @builder.io/qwik to @qwik.dev/core. + * + * - Operates on raw string (preserves JSONC comments). + * - Idempotent: no-op if already set to @qwik.dev/core. + * - Silent no-op if tsconfig.json does not exist. + * + * @param rootDir - Absolute path to the project root + */ +export function fixJsxImportSource(rootDir: string): void { + const tsconfigPath = join(rootDir, "tsconfig.json"); + let content: string; + try { + content = readFileSync(tsconfigPath, "utf-8"); + } catch { + return; // ENOENT or unreadable — silently skip + } + + const updated = content.replace( + /"jsxImportSource"\s*:\s*"@builder\.io\/qwik"/g, + '"jsxImportSource": "@qwik.dev/core"', + ); + + if (updated !== content) { + writeFileSync(tsconfigPath, updated, "utf-8"); + } +} + +/** + * CONF-02: Rewrite moduleResolution in tsconfig.json from Node/Node16 to Bundler. + * + * - Case-insensitive: matches "node", "Node", "NODE", "Node16", "node16". + * - Idempotent: no-op if already set to Bundler. + * - Silent no-op if tsconfig.json does not exist. + * + * @param rootDir - Absolute path to the project root + */ +export function fixModuleResolution(rootDir: string): void { + const tsconfigPath = join(rootDir, "tsconfig.json"); + let content: string; + try { + content = readFileSync(tsconfigPath, "utf-8"); + } catch { + return; // ENOENT or unreadable — silently skip + } + + const updated = content.replace( + /"moduleResolution"\s*:\s*"Node(?:16)?"/gi, + '"moduleResolution": "Bundler"', + ); + + if (updated !== content) { + writeFileSync(tsconfigPath, updated, "utf-8"); + } +} + +/** + * CONF-03: Add `"type": "module"` to package.json when absent. + * + * - Uses JSON.parse/stringify (standard JSON, no comments). + * - Idempotent: no-op if type is already "module". + * - Silent no-op if package.json does not exist. + * - Output always ends with a trailing newline. + * + * @param rootDir - Absolute path to the project root + */ +export function fixPackageType(rootDir: string): void { + const pkgPath = join(rootDir, "package.json"); + let raw: string; + try { + raw = readFileSync(pkgPath, "utf-8"); + } catch { + return; // ENOENT or unreadable — silently skip + } + + const obj = JSON.parse(raw) as Record; + if (obj["type"] === "module") { + return; // already set — idempotent + } + + obj["type"] = "module"; + writeFileSync(pkgPath, JSON.stringify(obj, null, 2) + "\n", "utf-8"); +} diff --git a/migrations/v2/rename-import.ts b/migrations/v2/rename-import.ts index ec2ac42..af8961d 100644 --- a/migrations/v2/rename-import.ts +++ b/migrations/v2/rename-import.ts @@ -22,9 +22,13 @@ export const IMPORT_RENAME_ROUNDS: ImportRenameRound[] = [ { library: "@builder.io/qwik-city", changes: [ - ["QwikCityProvider", "QwikRouterProvider"], + // NOTE: QwikCityProvider is NOT renamed here — Phase 16's structural + // transform (XFRM-04) handles it by removing the JSX element entirely + // and injecting useQwikRouter(). Renaming here would break that transform. ["QwikCityPlan", "QwikRouterConfig"], ["qwikCity", "qwikRouter"], + ["QwikCityMockProvider", "QwikRouterMockProvider"], // RNME-01 + ["QwikCityProps", "QwikRouterProps"], // RNME-02 ], }, { diff --git a/migrations/v2/run-migration.ts b/migrations/v2/run-migration.ts index 7a53608..8687cdc 100644 --- a/migrations/v2/run-migration.ts +++ b/migrations/v2/run-migration.ts @@ -1,6 +1,13 @@ import { join } from "node:path"; +import { applyTransforms } from "./apply-transforms.ts"; +import { fixJsxImportSource, fixModuleResolution, fixPackageType } from "./fix-config.ts"; import { IMPORT_RENAME_ROUNDS, replaceImportInFiles } from "./rename-import.ts"; import { runAllPackageReplacements } from "./replace-package.ts"; +import { makeQwikCityProviderTransform } from "./transforms/migrate-qwik-city-provider.ts"; +import { migrateQwikLabsTransform } from "./transforms/migrate-qwik-labs.ts"; +import { migrateUseComputedAsyncTransform } from "./transforms/migrate-use-computed-async.ts"; +import { migrateUseResourceTransform } from "./transforms/migrate-use-resource.ts"; +import { removeEagernessTransform } from "./transforms/remove-eagerness.ts"; import { checkTsMorphPreExisting, removeTsMorphFromPackageJson, @@ -15,7 +22,9 @@ import { visitNotIgnoredFiles } from "./visit-not-ignored.ts"; * Steps: * 1. Check ts-morph pre-existence (idempotency guard) * 2. AST import rename via oxc-parser + magic-string + * 2b. Behavioral AST transforms (eagerness removal, etc.) * 3. Text-based package string replacement (substring-safe order) + * 3b. Config validation (jsxImportSource, moduleResolution, package type) * 4. Conditionally remove ts-morph (only if it was NOT pre-existing) * 5. Resolve v2 versions and update dependencies * @@ -50,6 +59,19 @@ export async function runV2Migration(rootDir: string): Promise { replaceImportInFiles(round.changes, round.library, absolutePaths); } + // Step 2b: Behavioral AST transforms + console.log("Step 2b: Applying behavioral transforms..."); + const qwikCityProviderTransform = makeQwikCityProviderTransform(rootDir); + for (const filePath of absolutePaths) { + applyTransforms(filePath, [ + removeEagernessTransform, + migrateQwikLabsTransform, + migrateUseComputedAsyncTransform, + migrateUseResourceTransform, + qwikCityProviderTransform, + ]); + } + // Step 3: Text-based package replacement (substring-safe order) console.log("Step 3: Replacing package names..."); process.chdir(rootDir); @@ -59,6 +81,12 @@ export async function runV2Migration(rootDir: string): Promise { process.chdir(origCwd); } + // Step 3b: Validate config files + console.log("Step 3b: Validating config files..."); + fixJsxImportSource(rootDir); + fixModuleResolution(rootDir); + fixPackageType(rootDir); + // Step 4: Conditionally remove ts-morph console.log("Step 4: Cleaning up ts-morph..."); if (!tsMorphWasPreExisting) { diff --git a/migrations/v2/transforms/migrate-qwik-city-provider.ts b/migrations/v2/transforms/migrate-qwik-city-provider.ts new file mode 100644 index 0000000..ea13736 --- /dev/null +++ b/migrations/v2/transforms/migrate-qwik-city-provider.ts @@ -0,0 +1,305 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { Node } from "oxc-parser"; +import type { ParseResult } from "oxc-parser"; +import type { SourceReplacement, TransformFn } from "../types.ts"; +import { walkNode } from "./walk.ts"; + +// --------------------------------------------------------------------------- +// Type helpers for oxc-parser AST nodes used in this transform +// --------------------------------------------------------------------------- + +interface JSXIdentifier { + type: "JSXIdentifier"; + name: string; + start: number; + end: number; +} + +interface JSXOpeningElement { + type: "JSXOpeningElement"; + name: JSXIdentifier; + selfClosing: boolean; + start: number; + end: number; +} + +interface JSXClosingElement { + type: "JSXClosingElement"; + name: JSXIdentifier; + start: number; + end: number; +} + +interface JSXElement { + type: "JSXElement"; + openingElement: JSXOpeningElement; + closingElement: JSXClosingElement | null; + children: Node[]; + start: number; + end: number; +} + +interface BlockStatement { + type: "BlockStatement"; + body: Array<{ start: number; end: number; type: string }>; + start: number; + end: number; +} + +interface FunctionLike { + type: "ArrowFunctionExpression" | "FunctionExpression" | "FunctionDeclaration"; + body: BlockStatement | Node; + start: number; + end: number; +} + +interface ImportSpecifier { + type: "ImportSpecifier"; + imported: { name: string; start: number; end: number }; + local: { name: string; start: number; end: number }; + start: number; + end: number; +} + +interface ImportDeclaration { + type: "ImportDeclaration"; + source: { value: string }; + specifiers: ImportSpecifier[]; + start: number; + end: number; +} + +// --------------------------------------------------------------------------- +// Astro project detection +// --------------------------------------------------------------------------- + +/** + * Returns true if the project at `rootDir` is a Qwik Router project — + * detected by the presence of `@builder.io/qwik-city` in any dependency field. + * Returns false on missing or invalid package.json. + * + * Exported for direct unit testing. + */ +export function detectQwikRouterProject(rootDir: string): boolean { + try { + const pkg = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf-8")) as Record< + string, + unknown + >; + const allDeps = { + ...(pkg["dependencies"] as Record | undefined), + ...(pkg["devDependencies"] as Record | undefined), + ...(pkg["peerDependencies"] as Record | undefined), + }; + return "@builder.io/qwik-city" in allDeps; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Core transform logic (exported for direct testing without rootDir detection) +// --------------------------------------------------------------------------- + +/** + * The internal transform function that removes `` wrapper tags, + * injects `const router = useQwikRouter();` into the enclosing function body, + * and mutates the import specifier. + * + * Exported for direct unit testing. Use `makeQwikCityProviderTransform` for + * production use (includes Astro project detection via rootDir). + */ +export const qwikCityProviderTransform: TransformFn = ( + _filePath: string, + source: string, + parseResult: ParseResult, +): SourceReplacement[] => { + const replacements: SourceReplacement[] = []; + const program = parseResult.program as unknown as Node; + + // ----------------------------------------------------------------------- + // Step 1: Find all QwikCityProvider JSXElement nodes + // ----------------------------------------------------------------------- + const qcpElements: JSXElement[] = []; + + walkNode(program, (node: Node) => { + if (node.type !== "JSXElement") return; + const el = node as unknown as JSXElement; + if (el.openingElement?.name?.name === "QwikCityProvider") { + qcpElements.push(el); + } + }); + + if (qcpElements.length === 0) return []; + + // If multiple found, warn and process only the outermost (largest range). + // This guards against collision in applyTransforms. + const el = + qcpElements.length === 1 + ? qcpElements[0]! + : qcpElements.reduce((best, cur) => + cur.end - cur.start > best.end - best.start ? cur : best, + ); + + // ----------------------------------------------------------------------- + // Step 2: Remove opening tag + // ----------------------------------------------------------------------- + replacements.push({ + start: el.openingElement.start, + end: el.openingElement.end, + replacement: "", + }); + + // ----------------------------------------------------------------------- + // Step 3: Remove closing tag (guard against self-closing) + // ----------------------------------------------------------------------- + if (el.closingElement) { + replacements.push({ + start: el.closingElement.start, + end: el.closingElement.end, + replacement: "", + }); + } + + // ----------------------------------------------------------------------- + // Step 4: Inject hook at top of enclosing function body + // ----------------------------------------------------------------------- + const functionTypes = new Set([ + "ArrowFunctionExpression", + "FunctionExpression", + "FunctionDeclaration", + ]); + + const allFns: FunctionLike[] = []; + walkNode(program, (node: Node) => { + if (functionTypes.has(node.type)) { + const fn = node as unknown as FunctionLike; + if (fn.body && fn.body.type === "BlockStatement") { + allFns.push(fn); + } + } + }); + + // Find the smallest enclosing function that contains the QwikCityProvider element + const enclosingFn = allFns + .filter((fn) => fn.start <= el.start && el.end <= fn.end) + .reduce((best, cur) => { + if (!best) return cur; + // Prefer smallest (most immediate) enclosing function + return cur.end - cur.start < best.end - best.start ? cur : best; + }, null); + + if (enclosingFn) { + const block = enclosingFn.body as BlockStatement; + if (block.body.length > 0) { + const firstStmt = block.body[0]!; + const firstStmtStart = firstStmt.start; + replacements.push({ + start: firstStmtStart, + end: firstStmtStart + 1, + replacement: `const router = useQwikRouter();\n ${source[firstStmtStart]}`, + }); + } + } else { + console.warn( + `[migrate-qwik-city-provider] No enclosing function found for QwikCityProvider — skipping hook injection`, + ); + } + + // ----------------------------------------------------------------------- + // Step 5: Mutate the import specifier + // At Step 2b time, the import source is still @builder.io/qwik-city + // (Step 3 package replacement has not run yet). QwikCityProvider is NOT + // renamed by Step 2's import rename rounds — this transform handles it. + // ----------------------------------------------------------------------- + const bodyNodes = (program as unknown as { body: Node[] }).body; + + const importDecl = bodyNodes.find( + (stmt) => + stmt.type === "ImportDeclaration" && + (stmt as unknown as ImportDeclaration).source.value === "@builder.io/qwik-city", + ) as ImportDeclaration | undefined; + + if (!importDecl) return replacements; + + const specs = importDecl.specifiers; + const qcpSpecIdx = specs.findIndex( + (s) => s.type === "ImportSpecifier" && s.imported.name === "QwikCityProvider", + ); + + if (qcpSpecIdx === -1) return replacements; + + const qcpSpec = specs[qcpSpecIdx]!; + const hasUseQwikRouter = specs.some( + (s) => s.type === "ImportSpecifier" && s.imported.name === "useQwikRouter", + ); + + if (!hasUseQwikRouter) { + // Rename: replace QwikCityProvider specifier with useQwikRouter + replacements.push({ + start: qcpSpec.start, + end: qcpSpec.end, + replacement: "useQwikRouter", + }); + } else { + // Remove: QwikCityProvider specifier (useQwikRouter already present) + if (specs.length === 1) { + // Only specifier — remove entire ImportDeclaration + replacements.push({ + start: importDecl.start, + end: importDecl.end, + replacement: "", + }); + } else if (qcpSpecIdx < specs.length - 1) { + // Not last: remove from qcpSpec.start to nextSpec.start (removes "QwikCityProvider, ") + const nextSpec = specs[qcpSpecIdx + 1]!; + replacements.push({ + start: qcpSpec.start, + end: nextSpec.start, + replacement: "", + }); + } else { + // Last specifier: remove from prevSpec.end to qcpSpec.end (removes ", QwikCityProvider") + const prevSpec = specs[qcpSpecIdx - 1]!; + replacements.push({ + start: prevSpec.end, + end: qcpSpec.end, + replacement: "", + }); + } + } + + return replacements; +}; + +// --------------------------------------------------------------------------- +// Factory function (production use — includes Astro project detection) +// --------------------------------------------------------------------------- + +/** + * Factory that creates a `TransformFn` configured for the given project root. + * + * The returned `TransformFn`: + * - Detects whether the project is a Qwik Router app by reading `rootDir/package.json` + * once at factory call time (not per-file) + * - Returns `[]` for Astro projects (no `@builder.io/qwik-city` in package.json) + * - Otherwise delegates to `qwikCityProviderTransform` for the full rewrite + * + * @param rootDir - Absolute path to the project root (must contain package.json) + * @returns A `TransformFn` compatible with `applyTransforms` + */ +export function makeQwikCityProviderTransform(rootDir: string): TransformFn { + // Detect once at factory call time — not on every file + const isQwikRouterProject = detectQwikRouterProject(rootDir); + + return (filePath: string, source: string, parseResult: ParseResult): SourceReplacement[] => { + if (!isQwikRouterProject) { + console.warn( + `[migrate-qwik-city-provider] Skipping ${filePath} — @builder.io/qwik-city not found in package.json (Astro project?)`, + ); + return []; + } + return qwikCityProviderTransform(filePath, source, parseResult); + }; +} diff --git a/migrations/v2/transforms/migrate-qwik-labs.ts b/migrations/v2/transforms/migrate-qwik-labs.ts new file mode 100644 index 0000000..6e34454 --- /dev/null +++ b/migrations/v2/transforms/migrate-qwik-labs.ts @@ -0,0 +1,175 @@ +import type { Node } from "oxc-parser"; +import type { ParseResult } from "oxc-parser"; +import type { SourceReplacement, TransformFn } from "../types.ts"; +import { walkNode } from "./walk.ts"; + +/** + * Maps known @builder.io/qwik-labs export names to their v2 equivalents. + * Each entry specifies the target package and the new exported name. + */ +const KNOWN_LABS_APIS: Record = { + usePreventNavigate: { + pkg: "@qwik.dev/router", + exportName: "usePreventNavigate$", + }, +}; + +const QWIK_LABS_SOURCE = "@builder.io/qwik-labs"; + +/** + * AST transform that migrates @builder.io/qwik-labs imports to their v2 equivalents. + * + * For each ImportDeclaration from "@builder.io/qwik-labs": + * - If ALL specifiers are in KNOWN_LABS_APIS: rewrite each specifier's imported name + * and the import source to the mapped package. If the import is unaliased, also + * rename call sites throughout the file. + * - If ANY specifier is unknown: rename only the known specifiers (not the source), + * and insert a TODO comment before the import for each unknown specifier. + * + * This handles: + * - Plain imports: `import { usePreventNavigate } from "@builder.io/qwik-labs"` + * - Aliased imports: `import { usePreventNavigate as preventNav } from "@builder.io/qwik-labs"` + * - Unknown APIs: inserts TODO comment, leaves source unchanged + * - Mixed known+unknown: renames known, leaves source, adds TODO for unknown + * - Call site renaming for unaliased imports + */ +export const migrateQwikLabsTransform: TransformFn = ( + _filePath: string, + source: string, + parseResult: ParseResult, +): SourceReplacement[] => { + const replacements: SourceReplacement[] = []; + + // Track which identifiers need call-site renaming (old name -> new name). + // Only populated for unaliased imports where local.name === imported.name. + const callSiteRenames = new Map(); + + // Track import specifier node ranges so we can exclude them from call-site renaming. + // (The import specifier identifiers themselves are already handled by the import replacements.) + const importSpecifierRanges: Array<{ start: number; end: number }> = []; + + const program = parseResult.program as unknown as Node; + const body = (program as unknown as { body: Node[] }).body; + + for (const stmt of body) { + if (stmt.type !== "ImportDeclaration") continue; + + const importDecl = stmt as unknown as { + type: string; + start: number; + end: number; + source: { start: number; end: number; value: string }; + specifiers: Array<{ + type: string; + start: number; + end: number; + imported: { start: number; end: number; name: string; type: string }; + local: { start: number; end: number; name: string; type: string }; + }>; + }; + + if (importDecl.source.value !== QWIK_LABS_SOURCE) continue; + + const specifiers = importDecl.specifiers.filter((s) => s.type === "ImportSpecifier"); + if (specifiers.length === 0) continue; + + // Classify each specifier as known or unknown + const knownSpecifiers = specifiers.filter((s) => s.imported.name in KNOWN_LABS_APIS); + const unknownSpecifiers = specifiers.filter((s) => !(s.imported.name in KNOWN_LABS_APIS)); + const hasUnknown = unknownSpecifiers.length > 0; + + // Track import specifier ranges (both imported and local identifiers) + for (const spec of specifiers) { + importSpecifierRanges.push({ start: spec.start, end: spec.end }); + } + + if (hasUnknown) { + // Mixed or all-unknown: add TODO comment for unknown specifiers, rename known specifiers only + const unknownNames = unknownSpecifiers.map((s) => s.imported.name); + const todoComment = `// TODO: @builder.io/qwik-labs migration — ${unknownNames.join(", ")} has no known v2 equivalent; manual review required\n`; + + // Insert TODO before the import using first-char trick (zero-width overwrite workaround) + replacements.push({ + start: importDecl.start, + end: importDecl.start + 1, + replacement: todoComment + source[importDecl.start], + }); + + // Rename known specifiers' imported names (not the source) + for (const spec of knownSpecifiers) { + const mapping = KNOWN_LABS_APIS[spec.imported.name]!; + replacements.push({ + start: spec.imported.start, + end: spec.imported.end, + replacement: mapping.exportName, + }); + // Even in mixed case, track call site renames for unaliased known imports + if (spec.local.name === spec.imported.name) { + callSiteRenames.set(spec.imported.name, mapping.exportName); + } + } + } else { + // All specifiers are known — determine the target package + // (if all specifiers map to the same package, rewrite the source; otherwise keep it) + const targetPkgs = new Set(knownSpecifiers.map((s) => KNOWN_LABS_APIS[s.imported.name]!.pkg)); + const singleTarget = targetPkgs.size === 1 ? [...targetPkgs][0]! : null; + + // Rewrite each specifier's imported name + for (const spec of knownSpecifiers) { + const mapping = KNOWN_LABS_APIS[spec.imported.name]!; + replacements.push({ + start: spec.imported.start, + end: spec.imported.end, + replacement: mapping.exportName, + }); + + // Track call site renaming for unaliased imports + if (spec.local.name === spec.imported.name) { + callSiteRenames.set(spec.imported.name, mapping.exportName); + } + } + + // Rewrite the import source if all specifiers agree on a single target package + if (singleTarget !== null) { + // source text includes the quotes — replace them including quote characters + replacements.push({ + start: importDecl.source.start, + end: importDecl.source.end, + replacement: `"${singleTarget}"`, + }); + } + } + } + + // Walk the full AST to find call site identifiers that need renaming. + // Only rename Identifier nodes that are NOT within import specifier ranges. + if (callSiteRenames.size > 0) { + walkNode(program, (node: Node) => { + if (node.type !== "Identifier") return; + + const ident = node as unknown as { + type: string; + start: number; + end: number; + name: string; + }; + + const newName = callSiteRenames.get(ident.name); + if (!newName) return; + + // Skip if this identifier falls within an import specifier range + const isImportSpecifier = importSpecifierRanges.some( + (range) => ident.start >= range.start && ident.end <= range.end, + ); + if (isImportSpecifier) return; + + replacements.push({ + start: ident.start, + end: ident.end, + replacement: newName, + }); + }); + } + + return replacements; +}; diff --git a/migrations/v2/transforms/migrate-use-computed-async.ts b/migrations/v2/transforms/migrate-use-computed-async.ts new file mode 100644 index 0000000..e547adf --- /dev/null +++ b/migrations/v2/transforms/migrate-use-computed-async.ts @@ -0,0 +1,145 @@ +import type { Node } from "oxc-parser"; +import type { ParseResult } from "oxc-parser"; +import type { SourceReplacement, TransformFn } from "../types.ts"; +import { walkNode } from "./walk.ts"; + +const QWIK_SOURCES = ["@qwik.dev/core", "@builder.io/qwik"]; + +/** + * AST transform that migrates `useComputed$(async () => ...)` calls to `useAsync$`. + * + * In Qwik v2, async computed values should use `useAsync$` instead of `useComputed$`. + * This transform only rewrites call sites where the first argument is an async function — + * synchronous `useComputed$` calls are deliberately left unchanged. + * + * Behaviors: + * 1. `useComputed$(async () => ...)` — callee rewritten to `useAsync$` + * 2. Import specifier `useComputed$` renamed to `useAsync$` when ALL usages are async + * 3. Both `@qwik.dev/core` and `@builder.io/qwik` matched as import sources + * 4. Sync `useComputed$(() => ...)` is NOT rewritten + * 5. Mixed sync+async in same file — async call sites rewritten, import not renamed, TODO added + * 6. Works at any nesting depth (e.g., inside `component$`) + * 7. No `useComputed$` — returns empty replacements + */ +export const migrateUseComputedAsyncTransform: TransformFn = ( + _filePath: string, + source: string, + parseResult: ParseResult, +): SourceReplacement[] => { + const replacements: SourceReplacement[] = []; + + const program = parseResult.program as unknown as Node; + + // Track async and sync useComputed$ call sites + const asyncCallSites: Array<{ callee: { start: number; end: number } }> = []; + let hasSyncUsage = false; + + // Type for CallExpression callee + arguments + type CallNode = { + type: string; + start: number; + end: number; + callee: { type: string; name: string; start: number; end: number }; + arguments: Array<{ + type: string; + async?: boolean; + start: number; + end: number; + }>; + }; + + walkNode(program, (node: Node) => { + if (node.type !== "CallExpression") return; + + const call = node as unknown as CallNode; + + if (call.callee.type !== "Identifier" || call.callee.name !== "useComputed$") return; + if (call.arguments.length === 0) return; + + const firstArg = call.arguments[0]!; + const isAsync = + (firstArg.type === "ArrowFunctionExpression" || firstArg.type === "FunctionExpression") && + firstArg.async === true; + + if (isAsync) { + asyncCallSites.push({ callee: call.callee }); + } else { + hasSyncUsage = true; + } + }); + + // No async usages — nothing to do + if (asyncCallSites.length === 0) return []; + + // Rewrite each async call site: replace callee `useComputed$` with `useAsync$` + for (const { callee } of asyncCallSites) { + replacements.push({ + start: callee.start, + end: callee.end, + replacement: "useAsync$", + }); + } + + // Handle import specifier rewriting + const body = (program as unknown as { body: Node[] }).body; + + for (const stmt of body) { + if (stmt.type !== "ImportDeclaration") continue; + + const importDecl = stmt as unknown as { + type: string; + start: number; + end: number; + source: { start: number; end: number; value: string }; + specifiers: Array<{ + type: string; + start: number; + end: number; + imported: { start: number; end: number; name: string }; + local: { start: number; end: number; name: string }; + }>; + }; + + if (!QWIK_SOURCES.includes(importDecl.source.value)) continue; + + const specifier = importDecl.specifiers.find( + (s) => s.type === "ImportSpecifier" && s.imported.name === "useComputed$", + ); + if (!specifier) continue; + + if (hasSyncUsage) { + // Mixed sync + async: do NOT rename the import; instead, insert a TODO comment + const todoComment = `// TODO: This file uses both useComputed$ (sync) and useAsync$ (async); remove useComputed$ from imports if no sync usages remain\n`; + replacements.push({ + start: importDecl.start, + end: importDecl.start + 1, + replacement: todoComment + source[importDecl.start], + }); + } else { + // All async: rename the import specifier + replacements.push({ + start: specifier.imported.start, + end: specifier.imported.end, + replacement: "useAsync$", + }); + + // If unaliased (local name matches imported name), also rename the local binding + if (specifier.local.name === specifier.imported.name) { + // The local identifier is the same text node when unaliased — specifier.local covers it + // But we need to avoid double-replacing if imported and local occupy the same range + if ( + specifier.local.start !== specifier.imported.start || + specifier.local.end !== specifier.imported.end + ) { + replacements.push({ + start: specifier.local.start, + end: specifier.local.end, + replacement: "useAsync$", + }); + } + } + } + } + + return replacements; +}; diff --git a/migrations/v2/transforms/migrate-use-resource.ts b/migrations/v2/transforms/migrate-use-resource.ts new file mode 100644 index 0000000..e7e5c76 --- /dev/null +++ b/migrations/v2/transforms/migrate-use-resource.ts @@ -0,0 +1,144 @@ +import type { Node } from "oxc-parser"; +import type { ParseResult } from "oxc-parser"; +import type { SourceReplacement, TransformFn } from "../types.ts"; +import { walkNode } from "./walk.ts"; + +const QWIK_SOURCES = ["@qwik.dev/core", "@builder.io/qwik"]; + +const USE_RESOURCE_TODO = `// TODO: useResource$ -> useAsync$ migration — return type changed from ResourceReturn (.value is Promise) to AsyncSignal (.value is T). Review .value usage and component usage.\n`; + +/** + * AST transform that migrates `useResource$` calls to `useAsync$`. + * + * In Qwik v2, `useResource$` is deprecated. The replacement is `useAsync$`. + * Key difference: `ResourceReturn.value` is `Promise`, while + * `AsyncSignal.value` is the resolved `T` — callers must be updated. + * + * Behaviors: + * 1. `useResource$(async ({ track, cleanup }) => ...)` — callee rewritten to `useAsync$` + * 2. Import specifier `useResource$` renamed to `useAsync$` (both @qwik.dev/core and @builder.io/qwik) + * 3. TODO comment inserted before each call site about the return type change + * 4. Works at any nesting depth (e.g., inside `component$`) + * 5. Multiple `useResource$` calls in one file — all rewritten + * 6. No `useResource$` — returns empty replacements + */ +export const migrateUseResourceTransform: TransformFn = ( + _filePath: string, + source: string, + parseResult: ParseResult, +): SourceReplacement[] => { + const replacements: SourceReplacement[] = []; + + const program = parseResult.program as unknown as Node; + + type CallNode = { + type: string; + start: number; + end: number; + callee: { type: string; name: string; start: number; end: number }; + arguments: Array<{ type: string; start: number; end: number }>; + }; + + // Collect all useResource$ call sites + const callSites: CallNode[] = []; + + walkNode(program, (node: Node) => { + if (node.type !== "CallExpression") return; + + const call = node as unknown as CallNode; + if (call.callee.type !== "Identifier" || call.callee.name !== "useResource$") return; + + callSites.push(call); + }); + + if (callSites.length === 0) return []; + + // Build a map from call start -> enclosing statement start for TODO insertion + // We walk the body to find ExpressionStatement or VariableDeclaration containing each call + const body = (program as unknown as { body: Node[] }).body; + + // Helper: find the statement in body that contains a given call start position + function findEnclosingStatementStart(callStart: number): number | null { + for (const stmt of body) { + const s = stmt as unknown as { start: number; end: number }; + if (s.start <= callStart && callStart <= s.end) { + return s.start; + } + } + return null; + } + + // Track statement starts that already have a TODO comment inserted to avoid duplicates + const todoInserted = new Set(); + + for (const call of callSites) { + // Rewrite the callee from useResource$ to useAsync$ + replacements.push({ + start: call.callee.start, + end: call.callee.end, + replacement: "useAsync$", + }); + + // Insert TODO comment before the enclosing statement + const stmtStart = findEnclosingStatementStart(call.start); + const insertAt = stmtStart ?? call.start; + + if (!todoInserted.has(insertAt)) { + todoInserted.add(insertAt); + replacements.push({ + start: insertAt, + end: insertAt + 1, + replacement: USE_RESOURCE_TODO + source[insertAt], + }); + } + } + + // Handle import specifier rewriting + for (const stmt of body) { + if (stmt.type !== "ImportDeclaration") continue; + + const importDecl = stmt as unknown as { + type: string; + start: number; + end: number; + source: { start: number; end: number; value: string }; + specifiers: Array<{ + type: string; + start: number; + end: number; + imported: { start: number; end: number; name: string }; + local: { start: number; end: number; name: string }; + }>; + }; + + if (!QWIK_SOURCES.includes(importDecl.source.value)) continue; + + const specifier = importDecl.specifiers.find( + (s) => s.type === "ImportSpecifier" && s.imported.name === "useResource$", + ); + if (!specifier) continue; + + // Rename the imported specifier + replacements.push({ + start: specifier.imported.start, + end: specifier.imported.end, + replacement: "useAsync$", + }); + + // If unaliased, also rename the local binding if it occupies a different range + if (specifier.local.name === specifier.imported.name) { + if ( + specifier.local.start !== specifier.imported.start || + specifier.local.end !== specifier.imported.end + ) { + replacements.push({ + start: specifier.local.start, + end: specifier.local.end, + replacement: "useAsync$", + }); + } + } + } + + return replacements; +}; diff --git a/migrations/v2/transforms/remove-eagerness.ts b/migrations/v2/transforms/remove-eagerness.ts new file mode 100644 index 0000000..0eac9ab --- /dev/null +++ b/migrations/v2/transforms/remove-eagerness.ts @@ -0,0 +1,94 @@ +import type { Node } from "oxc-parser"; +import type { ParseResult } from "oxc-parser"; +import type { SourceReplacement, TransformFn } from "../types.ts"; +import { walkNode } from "./walk.ts"; + +/** + * AST transform that removes the `eagerness` option from `useVisibleTask$` calls. + * + * In Qwik v2, `useVisibleTask$` no longer accepts an `eagerness` option. + * This transform strips the option automatically so developers don't need to + * find and remove every instance by hand. + * + * Handles three cases: + * 1. Solo eagerness prop: `useVisibleTask$({eagerness: 'load'}, cb)` → `useVisibleTask$(cb)` + * 2. Eagerness first: `useVisibleTask$({eagerness: 'load', strategy: 'x'}, cb)` → `useVisibleTask$({strategy: 'x'}, cb)` + * 3. Eagerness last: `useVisibleTask$({strategy: 'x', eagerness: 'load'}, cb)` → `useVisibleTask$({strategy: 'x'}, cb)` + * + * Safely ignores: + * - Single-arg form: `useVisibleTask$(cb)` (no options object) + * - Options without eagerness: `useVisibleTask$({strategy: 'x'}, cb)` + * + * Works at any nesting depth — deeply embedded calls inside `component$` callbacks are found. + */ +export const removeEagernessTransform: TransformFn = ( + _filePath: string, + source: string, + parseResult: ParseResult, +): SourceReplacement[] => { + const replacements: SourceReplacement[] = []; + + const program = parseResult.program as unknown as Node; + + walkNode(program, (node: Node) => { + // Only process CallExpressions + if (node.type !== "CallExpression") return; + + const call = node as unknown as { + type: string; + callee: { type: string; name: string }; + arguments: Array<{ + type: string; + start: number; + end: number; + properties?: Array<{ + type: string; + start: number; + end: number; + key: { type: string; name: string }; + }>; + }>; + start: number; + end: number; + }; + + // Guard: must be `useVisibleTask$(...)` identifier call + if (call.callee.type !== "Identifier" || call.callee.name !== "useVisibleTask$") return; + + // Guard: must have at least 2 arguments, first must be an ObjectExpression + if (call.arguments.length < 2 || call.arguments[0]!.type !== "ObjectExpression") return; + + const opts = call.arguments[0]!; + const properties = opts.properties ?? []; + + // Find the eagerness property + const eagernessIdx = properties.findIndex( + (p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "eagerness", + ); + + // No eagerness property — nothing to do + if (eagernessIdx === -1) return; + + if (properties.length === 1) { + // Solo eagerness: remove the entire first argument including the trailing ", " + // opts.start to args[1].start covers: `{eagerness: 'load'}, ` + const secondArgStart = call.arguments[1]!.start; + replacements.push({ + start: opts.start, + end: secondArgStart, + replacement: "", + }); + } else { + // Multiple properties: keep the remaining ones, reconstruct the object + const remaining = properties.filter((_, i) => i !== eagernessIdx); + const newOpts = "{" + remaining.map((p) => source.slice(p.start, p.end)).join(", ") + "}"; + replacements.push({ + start: opts.start, + end: opts.end, + replacement: newOpts, + }); + } + }); + + return replacements; +}; diff --git a/migrations/v2/transforms/walk.ts b/migrations/v2/transforms/walk.ts new file mode 100644 index 0000000..c56ba02 --- /dev/null +++ b/migrations/v2/transforms/walk.ts @@ -0,0 +1,25 @@ +import type { Node } from "oxc-parser"; + +/** + * Recursively walk an AST node, visiting every descendant node. + * Iterates over all values of a node: arrays have each element with a `type` + * property walked; objects with a `type` property are walked directly. + * + * @param node - The root AST node to start walking from + * @param visitor - Called for every node encountered (including root) + */ +export function walkNode(node: Node, visitor: (node: Node) => void): void { + visitor(node); + + for (const value of Object.values(node)) { + if (Array.isArray(value)) { + for (const item of value) { + if (item !== null && typeof item === "object" && typeof item.type === "string") { + walkNode(item as Node, visitor); + } + } + } else if (value !== null && typeof value === "object" && typeof value.type === "string") { + walkNode(value as Node, visitor); + } + } +} diff --git a/migrations/v2/types.ts b/migrations/v2/types.ts new file mode 100644 index 0000000..b2d4633 --- /dev/null +++ b/migrations/v2/types.ts @@ -0,0 +1,27 @@ +import type { ParseResult } from "oxc-parser"; + +/** + * A single source replacement to be applied via magic-string. + * All positions are byte offsets into the original source string. + */ +export interface SourceReplacement { + start: number; + end: number; + replacement: string; +} + +/** + * A transform function that inspects a parsed source file and returns + * the list of replacements to apply. Transforms must be pure — they + * only read from `source` and `parseResult`, never write to disk. + * + * @param filePath - Absolute path to the file being transformed + * @param source - Raw source text of the file + * @param parseResult - oxc-parser ParseResult (parse-once, shared across transforms) + * @returns Array of replacements; return [] for a no-op + */ +export type TransformFn = ( + filePath: string, + source: string, + parseResult: ParseResult, +) => SourceReplacement[]; diff --git a/tests/unit/upgrade/apply-transforms.spec.ts b/tests/unit/upgrade/apply-transforms.spec.ts new file mode 100644 index 0000000..279e461 --- /dev/null +++ b/tests/unit/upgrade/apply-transforms.spec.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { applyTransforms } from "../../../migrations/v2/apply-transforms.ts"; +import type { SourceReplacement, TransformFn } from "../../../migrations/v2/types.ts"; + +describe("applyTransforms", () => { + let tmpDir: string; + let tmpFile: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "apply-transforms-test-")); + tmpFile = join(tmpDir, "test-file.ts"); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("Test 1: two non-overlapping stub transforms produce combined output without throwing", () => { + // Source: 'import { foo } from "a"; import { bar } from "b";' + const source = 'import { foo } from "a"; import { bar } from "b";'; + writeFileSync(tmpFile, source, "utf-8"); + + // Transform 1: replaces "foo" (indices 9-12) + const transform1: TransformFn = (_filePath, src, _parseResult) => { + const start = src.indexOf("foo"); + const end = start + 3; + return [{ start, end, replacement: "FOO" }]; + }; + + // Transform 2: replaces "bar" (indices 34-37 approx) + const transform2: TransformFn = (_filePath, src, _parseResult) => { + const start = src.indexOf("bar"); + const end = start + 3; + return [{ start, end, replacement: "BAR" }]; + }; + + expect(() => applyTransforms(tmpFile, [transform1, transform2])).not.toThrow(); + + const result = readFileSync(tmpFile, "utf-8"); + expect(result).toContain("FOO"); + expect(result).toContain("BAR"); + expect(result).not.toContain('"foo"'); + expect(result).not.toContain('"bar"'); + }); + + it("Test 2: applyTransforms with empty transforms array does not throw and does not modify file", () => { + const source = "const x = 1;\n"; + writeFileSync(tmpFile, source, "utf-8"); + + const before = readFileSync(tmpFile, "utf-8"); + expect(() => applyTransforms(tmpFile, [])).not.toThrow(); + const after = readFileSync(tmpFile, "utf-8"); + + expect(after).toBe(before); + }); + + it("Test 3: a transform returning empty SourceReplacement[] does not modify the file", () => { + const source = "const y = 2;\n"; + writeFileSync(tmpFile, source, "utf-8"); + + const noOpTransform: TransformFn = () => []; + + const before = readFileSync(tmpFile, "utf-8"); + expect(() => applyTransforms(tmpFile, [noOpTransform])).not.toThrow(); + const after = readFileSync(tmpFile, "utf-8"); + + expect(after).toBe(before); + }); + + it("Test 4: applyTransforms correctly sorts replacements descending by start before applying", () => { + // Use a source where applying in wrong order would corrupt offsets + // "aaa bbb ccc" — replace "aaa" at 0-3 and "ccc" at 8-11 + const source = "aaa bbb ccc"; + writeFileSync(tmpFile, source, "utf-8"); + + // Transform 1 gives later offset first (ccc) + const transform1: TransformFn = (_filePath, src, _parseResult): SourceReplacement[] => { + const start = src.indexOf("ccc"); + return [{ start, end: start + 3, replacement: "CCC" }]; + }; + + // Transform 2 gives earlier offset (aaa) + const transform2: TransformFn = (_filePath, src, _parseResult): SourceReplacement[] => { + const start = src.indexOf("aaa"); + return [{ start, end: start + 3, replacement: "AAA" }]; + }; + + expect(() => applyTransforms(tmpFile, [transform1, transform2])).not.toThrow(); + + const result = readFileSync(tmpFile, "utf-8"); + expect(result).toBe("AAA bbb CCC"); + }); + + it("Test 5: overlapping replacements from two transforms cause a descriptive error (magic-string collision)", () => { + // "hello world" — both transforms try to replace the same range + const source = "hello world"; + writeFileSync(tmpFile, source, "utf-8"); + + const overlap1: TransformFn = (): SourceReplacement[] => { + return [{ start: 0, end: 5, replacement: "HELLO" }]; + }; + + const overlap2: TransformFn = (): SourceReplacement[] => { + return [{ start: 0, end: 5, replacement: "GREET" }]; + }; + + expect(() => applyTransforms(tmpFile, [overlap1, overlap2])).toThrow(); + }); +}); diff --git a/tests/unit/upgrade/binary-extensions.spec.ts b/tests/unit/upgrade/binary-extensions.spec.ts new file mode 100644 index 0000000..1e32a57 --- /dev/null +++ b/tests/unit/upgrade/binary-extensions.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { BINARY_EXTENSIONS, isBinaryPath } from "../../../migrations/v2/binary-extensions.ts"; + +describe("isBinaryPath - images", () => { + it("returns true for common image extensions", () => { + const images = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".avif", ".svg"]; + for (const ext of images) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - fonts", () => { + it("returns true for font extensions", () => { + const fonts = [".woff", ".woff2", ".ttf", ".otf", ".eot"]; + for (const ext of fonts) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - archives", () => { + it("returns true for archive extensions", () => { + const archives = [".zip", ".gz", ".tar", ".7z", ".tgz"]; + for (const ext of archives) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - executables", () => { + it("returns true for executable extensions", () => { + const executables = [".exe", ".dll", ".so", ".dylib", ".bin"]; + for (const ext of executables) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - audio", () => { + it("returns true for audio extensions", () => { + const audio = [".mp3", ".wav", ".ogg", ".flac", ".aac"]; + for (const ext of audio) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - video", () => { + it("returns true for video extensions", () => { + const video = [".mp4", ".avi", ".mov", ".mkv", ".webm"]; + for (const ext of video) { + expect(isBinaryPath(`file${ext}`), `expected true for ${ext}`).toBe(true); + } + }); +}); + +describe("isBinaryPath - other essentials", () => { + it("returns true for .wasm, .pdf, .sqlite", () => { + expect(isBinaryPath("module.wasm")).toBe(true); + expect(isBinaryPath("doc.pdf")).toBe(true); + expect(isBinaryPath("data.sqlite")).toBe(true); + }); +}); + +describe("isBinaryPath - source code", () => { + it("returns false for source code extensions", () => { + const sourceFiles = [".ts", ".tsx", ".js", ".json", ".css", ".html", ".md"]; + for (const ext of sourceFiles) { + expect(isBinaryPath(`file${ext}`), `expected false for ${ext}`).toBe(false); + } + }); +}); + +describe("BINARY_EXTENSIONS set", () => { + it("has between 40 and 60 entries", () => { + expect(BINARY_EXTENSIONS.size).toBeGreaterThanOrEqual(40); + expect(BINARY_EXTENSIONS.size).toBeLessThanOrEqual(60); + }); + + it("has no duplicate entries", () => { + const asArray = Array.from(BINARY_EXTENSIONS); + const asSet = new Set(asArray); + expect(asArray.length).toBe(asSet.size); + }); +}); diff --git a/tests/unit/upgrade/fix-config.spec.ts b/tests/unit/upgrade/fix-config.spec.ts new file mode 100644 index 0000000..b9590cf --- /dev/null +++ b/tests/unit/upgrade/fix-config.spec.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { + fixJsxImportSource, + fixModuleResolution, + fixPackageType, +} from "../../../migrations/v2/fix-config.ts"; + +// Helper to create a temp directory for testing +function withTempDir(callback: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "fix-config-test-")); + try { + callback(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +// ----------------------------------------------------------------------- +// CONF-01: fixJsxImportSource +// ----------------------------------------------------------------------- + +describe("fixJsxImportSource - CONF-01: rewrites @builder.io/qwik to @qwik.dev/core", () => { + it("rewrites jsxImportSource from @builder.io/qwik to @qwik.dev/core", () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + writeFileSync( + tsconfig, + JSON.stringify( + { + compilerOptions: { + jsxImportSource: "@builder.io/qwik", + }, + }, + null, + 2, + ), + ); + + fixJsxImportSource(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toContain("@qwik.dev/core"); + expect(result).not.toContain("@builder.io/qwik"); + }); + }); +}); + +describe("fixJsxImportSource - CONF-01 idempotent: already @qwik.dev/core produces no file write", () => { + it("does not modify the file when jsxImportSource is already @qwik.dev/core", () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + const content = JSON.stringify( + { + compilerOptions: { + jsxImportSource: "@qwik.dev/core", + }, + }, + null, + 2, + ); + writeFileSync(tsconfig, content); + + fixJsxImportSource(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toBe(content); + }); + }); +}); + +describe("fixJsxImportSource - CONF-01 JSONC: block comments are preserved", () => { + it("preserves block comments in tsconfig with jsxImportSource rewrite", () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + const content = `{ + /* TypeScript configuration */ + "compilerOptions": { + "jsxImportSource": "@builder.io/qwik" + } +}`; + writeFileSync(tsconfig, content); + + fixJsxImportSource(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toContain("/* TypeScript configuration */"); + expect(result).toContain("@qwik.dev/core"); + expect(result).not.toContain("@builder.io/qwik"); + }); + }); +}); + +describe("fixJsxImportSource - CONF-01 missing file: silently returns when tsconfig.json absent", () => { + it("does not throw when tsconfig.json does not exist", () => { + withTempDir((dir) => { + expect(() => fixJsxImportSource(dir)).not.toThrow(); + }); + }); +}); + +// ----------------------------------------------------------------------- +// CONF-02: fixModuleResolution +// ----------------------------------------------------------------------- + +describe("fixModuleResolution - CONF-02: rewrites Node/Node16 to Bundler", () => { + it('rewrites moduleResolution "Node" to "Bundler"', () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + writeFileSync( + tsconfig, + JSON.stringify({ compilerOptions: { moduleResolution: "Node" } }, null, 2), + ); + + fixModuleResolution(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toContain('"Bundler"'); + expect(result).not.toContain('"Node"'); + }); + }); + + it('rewrites moduleResolution "Node16" to "Bundler"', () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + writeFileSync( + tsconfig, + JSON.stringify({ compilerOptions: { moduleResolution: "Node16" } }, null, 2), + ); + + fixModuleResolution(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toContain('"Bundler"'); + expect(result).not.toContain('"Node16"'); + }); + }); + + it('rewrites case-insensitive "node" to "Bundler"', () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + const content = `{\n "compilerOptions": {\n "moduleResolution": "node"\n }\n}`; + writeFileSync(tsconfig, content); + + fixModuleResolution(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toContain('"Bundler"'); + }); + }); +}); + +describe("fixModuleResolution - CONF-02 idempotent: already Bundler produces no file write", () => { + it("does not modify the file when moduleResolution is already Bundler", () => { + withTempDir((dir) => { + const tsconfig = join(dir, "tsconfig.json"); + const content = JSON.stringify({ compilerOptions: { moduleResolution: "Bundler" } }, null, 2); + writeFileSync(tsconfig, content); + + fixModuleResolution(dir); + + const result = readFileSync(tsconfig, "utf-8"); + expect(result).toBe(content); + }); + }); +}); + +describe("fixModuleResolution - CONF-02 missing file: silently returns when tsconfig.json absent", () => { + it("does not throw when tsconfig.json does not exist", () => { + withTempDir((dir) => { + expect(() => fixModuleResolution(dir)).not.toThrow(); + }); + }); +}); + +// ----------------------------------------------------------------------- +// CONF-03: fixPackageType +// ----------------------------------------------------------------------- + +describe('fixPackageType - CONF-03: adds type: "module" to package.json when absent', () => { + it('adds "type": "module" when package.json has no type field', () => { + withTempDir((dir) => { + const pkgPath = join(dir, "package.json"); + writeFileSync(pkgPath, JSON.stringify({ name: "my-app", version: "1.0.0" }, null, 2)); + + fixPackageType(dir); + + const result = JSON.parse(readFileSync(pkgPath, "utf-8")); + expect(result.type).toBe("module"); + }); + }); + + it("output ends with a trailing newline", () => { + withTempDir((dir) => { + const pkgPath = join(dir, "package.json"); + writeFileSync(pkgPath, JSON.stringify({ name: "my-app" }, null, 2)); + + fixPackageType(dir); + + const raw = readFileSync(pkgPath, "utf-8"); + expect(raw.endsWith("\n")).toBe(true); + }); + }); +}); + +describe('fixPackageType - CONF-03 idempotent: already has type "module" produces no file write', () => { + it("does not modify the file when type is already module", () => { + withTempDir((dir) => { + const pkgPath = join(dir, "package.json"); + const content = + JSON.stringify({ name: "my-app", version: "1.0.0", type: "module" }, null, 2) + "\n"; + writeFileSync(pkgPath, content); + + fixPackageType(dir); + + const result = readFileSync(pkgPath, "utf-8"); + expect(result).toBe(content); + }); + }); +}); + +describe("fixPackageType - CONF-03 missing file: silently returns when package.json absent", () => { + it("does not throw when package.json does not exist", () => { + withTempDir((dir) => { + expect(() => fixPackageType(dir)).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/upgrade/migrate-qwik-city-provider.spec.ts b/tests/unit/upgrade/migrate-qwik-city-provider.spec.ts new file mode 100644 index 0000000..384fc31 --- /dev/null +++ b/tests/unit/upgrade/migrate-qwik-city-provider.spec.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { parseSync } from "oxc-parser"; +import MagicString from "magic-string"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + qwikCityProviderTransform, + detectQwikRouterProject, +} from "../../../migrations/v2/transforms/migrate-qwik-city-provider.ts"; +import type { SourceReplacement } from "../../../migrations/v2/types.ts"; + +/** + * Apply a list of SourceReplacements to a source string using MagicString. + * Mirrors the logic in applyTransforms — sort descending by start, then overwrite. + * This is inlined here for test isolation (no file I/O needed). + */ +function applyReplacements(source: string, replacements: SourceReplacement[]): string { + if (replacements.length === 0) return source; + const sorted = [...replacements].sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + for (const { start, end, replacement } of sorted) { + ms.overwrite(start, end, replacement); + } + return ms.toString(); +} + +function transform(source: string): string { + const filePath = "root.tsx"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = qwikCityProviderTransform(filePath, source, parseResult); + return applyReplacements(source, replacements); +} + +// ----------------------------------------------------------------------- +// Test 1: Standard rewrite — opening/closing tags removed, children preserved, +// const router = useQwikRouter() injected, import renamed +// ----------------------------------------------------------------------- +describe("qwikCityProviderTransform - standard rewrite: QwikCityProvider -> useQwikRouter()", () => { + it("removes opening and closing tags, injects hook, renames import specifier", () => { + const source = `import { QwikCityProvider, RouterOutlet } from "@builder.io/qwik-city"; +import { component$ } from "@qwik.dev/core"; + +export default component$(() => { + return ( + + + + + + + + + ); +});`; + const result = transform(source); + + // Opening and closing QwikCityProvider tags are removed + expect(result).not.toContain(""); + expect(result).not.toContain(""); + + // Children are preserved intact + expect(result).toContain(""); + expect(result).toContain(''); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain(""); + + // Hook injected before return + expect(result).toContain("const router = useQwikRouter();"); + const hookIdx = result.indexOf("const router = useQwikRouter();"); + const returnIdx = result.indexOf("return ("); + expect(hookIdx).toBeLessThan(returnIdx); + + // Import specifier renamed: QwikCityProvider -> useQwikRouter + expect(result).toContain("useQwikRouter"); + expect(result).not.toContain("QwikCityProvider"); + // RouterOutlet still present in import + expect(result).toContain("RouterOutlet"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 2: Astro project skip — detectQwikRouterProject returns false +// when package.json lacks @builder.io/qwik-city +// ----------------------------------------------------------------------- +describe("detectQwikRouterProject - Astro project: returns false when @builder.io/qwik-city absent", () => { + it("returns false when package.json has no @builder.io/qwik-city dependency", () => { + const tmpDir = mkdtempSync(join(tmpdir(), "qwik-test-astro-")); + writeFileSync( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "my-astro-project", + dependencies: { + astro: "^4.0.0", + "@astrojs/qwik": "^0.5.0", + }, + devDependencies: { + typescript: "^5.0.0", + }, + }), + ); + const result = detectQwikRouterProject(tmpDir); + expect(result).toBe(false); + }); + + it("returns true when package.json has @builder.io/qwik-city in dependencies", () => { + const tmpDir = mkdtempSync(join(tmpdir(), "qwik-test-router-")); + writeFileSync( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "my-qwik-project", + dependencies: { + "@builder.io/qwik-city": "^1.9.0", + "@builder.io/qwik": "^1.9.0", + }, + }), + ); + const result = detectQwikRouterProject(tmpDir); + expect(result).toBe(true); + }); + + it("returns true when @builder.io/qwik-city is in devDependencies", () => { + const tmpDir = mkdtempSync(join(tmpdir(), "qwik-test-router-dev-")); + writeFileSync( + join(tmpDir, "package.json"), + JSON.stringify({ + name: "my-qwik-project", + devDependencies: { + "@builder.io/qwik-city": "^1.9.0", + }, + }), + ); + const result = detectQwikRouterProject(tmpDir); + expect(result).toBe(true); + }); + + it("returns false when package.json does not exist", () => { + const result = detectQwikRouterProject("/tmp/__non_existent_path_12345__"); + expect(result).toBe(false); + }); +}); + +// ----------------------------------------------------------------------- +// Test 3: Deeply nested children — all preserved intact after transform +// ----------------------------------------------------------------------- +describe("qwikCityProviderTransform - nested children: deeply nested elements preserved", () => { + it("preserves deeply nested JSX children unchanged after QwikCityProvider tag removal", () => { + const source = `import { QwikCityProvider } from "@builder.io/qwik-city"; +import { component$ } from "@qwik.dev/core"; + +export default component$(() => { + return ( + +
+ +

deep

+
+
+
+ ); +});`; + const result = transform(source); + + // Tags removed + expect(result).not.toContain(""); + expect(result).not.toContain(""); + + // All nested elements preserved + expect(result).toContain("
"); + expect(result).toContain("
"); + expect(result).toContain(""); + expect(result).toContain(""); + expect(result).toContain("

deep

"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 4: useQwikRouter already imported — QwikCityProvider specifier removed +// (not renamed), no duplicate useQwikRouter in output import +// ----------------------------------------------------------------------- +describe("qwikCityProviderTransform - useQwikRouter already imported: QwikCityProvider removed", () => { + it("removes QwikCityProvider specifier when useQwikRouter is already in the import", () => { + const source = `import { QwikCityProvider, useQwikRouter, RouterOutlet } from "@builder.io/qwik-city"; +import { component$ } from "@qwik.dev/core"; + +export default component$(() => { + return ( + + + + + ); +});`; + const result = transform(source); + + // QwikCityProvider specifier is removed from import + expect(result).not.toContain("QwikCityProvider"); + + // useQwikRouter appears exactly once in the import (no duplicate) + const importMatch = result.match( + /import\s*\{([^}]+)\}\s*from\s*["']@builder\.io\/qwik-city["']/, + ); + expect(importMatch).not.toBeNull(); + const specifiers = importMatch![1]!; + const useQwikRouterOccurrences = (specifiers.match(/useQwikRouter/g) || []).length; + expect(useQwikRouterOccurrences).toBe(1); + + // RouterOutlet still present + expect(result).toContain("RouterOutlet"); + }); +}); diff --git a/tests/unit/upgrade/migrate-qwik-labs.spec.ts b/tests/unit/upgrade/migrate-qwik-labs.spec.ts new file mode 100644 index 0000000..6f470d3 --- /dev/null +++ b/tests/unit/upgrade/migrate-qwik-labs.spec.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { parseSync } from "oxc-parser"; +import MagicString from "magic-string"; +import { migrateQwikLabsTransform } from "../../../migrations/v2/transforms/migrate-qwik-labs.ts"; +import type { SourceReplacement } from "../../../migrations/v2/types.ts"; + +/** + * Apply a list of SourceReplacements to a source string using MagicString. + * Mirrors the logic in applyTransforms — sort descending by start, then overwrite. + * This is inlined here for test isolation (no file I/O needed). + */ +function applyReplacements(source: string, replacements: SourceReplacement[]): string { + if (replacements.length === 0) return source; + const sorted = [...replacements].sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + for (const { start, end, replacement } of sorted) { + ms.overwrite(start, end, replacement); + } + return ms.toString(); +} + +function transform(source: string): string { + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateQwikLabsTransform(filePath, source, parseResult); + return applyReplacements(source, replacements); +} + +// ----------------------------------------------------------------------- +// Behavior 1: Known API — rewrites specifier and source +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - known API: rewrites specifier and source", () => { + it("rewrites usePreventNavigate to usePreventNavigate$ in @qwik.dev/router", () => { + const source = `import { usePreventNavigate } from "@builder.io/qwik-labs";`; + const result = transform(source); + expect(result).toBe(`import { usePreventNavigate$ } from "@qwik.dev/router";`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 2: Aliased known API — rewrites imported name, preserves local alias, rewrites source +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - aliased known API: preserves local alias", () => { + it("rewrites imported name to usePreventNavigate$, preserves alias preventNav, rewrites source", () => { + const source = `import { usePreventNavigate as preventNav } from "@builder.io/qwik-labs";`; + const result = transform(source); + expect(result).toBe(`import { usePreventNavigate$ as preventNav } from "@qwik.dev/router";`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 3: Unknown API — inserts TODO comment, leaves source unchanged +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - unknown API: inserts TODO comment, leaves source unchanged", () => { + it("inserts TODO comment above import and leaves source unchanged for unknown API", () => { + const source = `import { someUnknownApi } from "@builder.io/qwik-labs";`; + const result = transform(source); + expect(result).toContain( + `// TODO: @builder.io/qwik-labs migration — someUnknownApi has no known v2 equivalent; manual review required`, + ); + expect(result).toContain(`import { someUnknownApi } from "@builder.io/qwik-labs";`); + expect(result).not.toContain(`@qwik.dev/router`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 4: Mixed known + unknown — renames known specifier, leaves source, adds TODO +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - mixed known+unknown: renames known specifier, leaves source, adds TODO", () => { + it("renames known specifier but leaves source as @builder.io/qwik-labs and adds TODO for unknown", () => { + const source = `import { usePreventNavigate, someUnknownApi } from "@builder.io/qwik-labs";`; + const result = transform(source); + expect(result).toContain(`usePreventNavigate$`); + expect(result).toContain(`@builder.io/qwik-labs`); + expect(result).not.toContain(`@qwik.dev/router`); + expect(result).toContain(`someUnknownApi has no known v2 equivalent`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 5: No qwik-labs import — returns empty replacements (no-op) +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - no qwik-labs import: no-op", () => { + it("returns empty replacements for file with no @builder.io/qwik-labs import", () => { + const source = `import { component$ } from "@qwik.dev/core";`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateQwikLabsTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 6: Multiple qwik-labs imports in one file — both are processed +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - multiple qwik-labs imports: all processed", () => { + it("processes two separate @builder.io/qwik-labs import declarations", () => { + const source = [ + `import { usePreventNavigate } from "@builder.io/qwik-labs";`, + `import { someUnknownApi } from "@builder.io/qwik-labs";`, + ].join("\n"); + const result = transform(source); + expect(result).toContain(`usePreventNavigate$ } from "@qwik.dev/router"`); + expect(result).toContain(`someUnknownApi has no known v2 equivalent`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 7: Usage renaming — unaliased call sites renamed to usePreventNavigate$() +// ----------------------------------------------------------------------- +describe("migrateQwikLabsTransform - usage renaming: call sites renamed for unaliased import", () => { + it("renames usePreventNavigate() call site to usePreventNavigate$() when import was unaliased", () => { + const source = [ + `import { usePreventNavigate } from "@builder.io/qwik-labs";`, + ``, + `export const MyComponent = component$(() => {`, + ` const navigate = usePreventNavigate();`, + `});`, + ].join("\n"); + const result = transform(source); + expect(result).toContain(`usePreventNavigate$()`); + expect(result).not.toContain(`usePreventNavigate()`); + }); +}); diff --git a/tests/unit/upgrade/migrate-use-computed-async.spec.ts b/tests/unit/upgrade/migrate-use-computed-async.spec.ts new file mode 100644 index 0000000..c244714 --- /dev/null +++ b/tests/unit/upgrade/migrate-use-computed-async.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { parseSync } from "oxc-parser"; +import MagicString from "magic-string"; +import { migrateUseComputedAsyncTransform } from "../../../migrations/v2/transforms/migrate-use-computed-async.ts"; +import type { SourceReplacement } from "../../../migrations/v2/types.ts"; + +/** + * Apply a list of SourceReplacements to a source string using MagicString. + * Mirrors the logic in applyTransforms — sort descending by start, then overwrite. + * This is inlined here for test isolation (no file I/O needed). + */ +function applyReplacements(source: string, replacements: SourceReplacement[]): string { + if (replacements.length === 0) return source; + const sorted = [...replacements].sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + for (const { start, end, replacement } of sorted) { + ms.overwrite(start, end, replacement); + } + return ms.toString(); +} + +function transform(source: string): string { + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateUseComputedAsyncTransform(filePath, source, parseResult); + return applyReplacements(source, replacements); +} + +// ----------------------------------------------------------------------- +// Test 1: Async useComputed$ — callee rewritten to useAsync$ +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - async useComputed$: rewrites callee to useAsync$", () => { + it("rewrites useComputed$(async () => ...) to useAsync$(async () => ...)", () => { + const source = `const data = useComputed$(async () => await fetchData());`; + const result = transform(source); + expect(result).toContain("useAsync$"); + expect(result).not.toContain("useComputed$"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 2: Import from @qwik.dev/core — import specifier rewritten when all usages are async +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - import from @qwik.dev/core: specifier renamed", () => { + it("rewrites useComputed$ to useAsync$ in import when all usages are async", () => { + const source = `import { useComputed$ } from "@qwik.dev/core"; +const data = useComputed$(async () => await fetchData());`; + const result = transform(source); + expect(result).toContain('import { useAsync$ } from "@qwik.dev/core"'); + expect(result).not.toContain("useComputed$"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 3: Import from @builder.io/qwik — also matched as import source +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - import from @builder.io/qwik: specifier renamed", () => { + it("rewrites useComputed$ to useAsync$ in import from @builder.io/qwik when all usages are async", () => { + const source = `import { useComputed$ } from "@builder.io/qwik"; +const data = useComputed$(async () => await fetchData());`; + const result = transform(source); + expect(result).toContain('import { useAsync$ } from "@builder.io/qwik"'); + expect(result).not.toContain("useComputed$"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 4: Sync useComputed$ — NOT rewritten +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - sync useComputed$: NOT rewritten", () => { + it("returns empty replacements for sync useComputed$(() => x + y)", () => { + const source = `const sum = useComputed$(() => x + y);`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateUseComputedAsyncTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); + +// ----------------------------------------------------------------------- +// Test 5: Mixed sync + async in same file — async rewritten, sync left alone, TODO added +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - mixed sync+async: async rewritten, sync left, TODO added", () => { + it("rewrites only async useComputed$ calls and inserts TODO comment on import", () => { + const source = `import { useComputed$ } from "@qwik.dev/core"; +const sync = useComputed$(() => x + y); +const async_ = useComputed$(async () => await fetchData());`; + const result = transform(source); + // Async call site rewritten + expect(result).toContain("useAsync$(async () => await fetchData())"); + // Sync call site NOT rewritten + expect(result).toContain("useComputed$(() => x + y)"); + // Import NOT renamed (mixed usage) — useComputed$ still present + expect(result).toContain("useComputed$"); + // TODO comment added above import + expect(result).toContain("TODO:"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 6: Nested in component$ — deeply nested async useComputed$ is found and transformed +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - nested in component$: deep traversal finds the call", () => { + it("transforms useComputed$(async ...) nested inside component$ callback", () => { + const source = `export default component$(() => { + const data = useComputed$(async () => { + return await loadData(); + }); +})`; + const result = transform(source); + expect(result).toContain("useAsync$(async () => {"); + expect(result).not.toContain("useComputed$"); + }); +}); + +// ----------------------------------------------------------------------- +// Test 7: No useComputed$ — returns empty replacements +// ----------------------------------------------------------------------- +describe("migrateUseComputedAsyncTransform - no useComputed$: returns empty replacements", () => { + it("returns empty replacements when no useComputed$ is present", () => { + const source = `const x = useTask$(async () => doWork());`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateUseComputedAsyncTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); diff --git a/tests/unit/upgrade/migrate-use-resource.spec.ts b/tests/unit/upgrade/migrate-use-resource.spec.ts new file mode 100644 index 0000000..2e105a5 --- /dev/null +++ b/tests/unit/upgrade/migrate-use-resource.spec.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { parseSync } from "oxc-parser"; +import MagicString from "magic-string"; +import { migrateUseResourceTransform } from "../../../migrations/v2/transforms/migrate-use-resource.ts"; +import type { SourceReplacement } from "../../../migrations/v2/types.ts"; + +/** + * Apply a list of SourceReplacements to a source string using MagicString. + * Mirrors the logic in applyTransforms — sort descending by start, then overwrite. + * This is inlined here for test isolation (no file I/O needed). + */ +function applyReplacements(source: string, replacements: SourceReplacement[]): string { + if (replacements.length === 0) return source; + const sorted = [...replacements].sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + for (const { start, end, replacement } of sorted) { + ms.overwrite(start, end, replacement); + } + return ms.toString(); +} + +function transform(source: string): string { + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateUseResourceTransform(filePath, source, parseResult); + return applyReplacements(source, replacements); +} + +// ----------------------------------------------------------------------- +// Test 1: useResource$ call — callee rewritten to useAsync$ +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - useResource$ call: rewrites callee to useAsync$", () => { + it("rewrites useResource$(async ({ track, cleanup }) => ...) to useAsync$", () => { + const source = `const res = useResource$(async ({ track, cleanup }) => { + track(() => props.id); + return await fetchData(props.id); +});`; + const result = transform(source); + expect(result).toContain("useAsync$(async ({ track, cleanup }) => {"); + // The TODO comment may contain "useResource$" literally; the call site must be rewritten + expect(result).not.toContain("= useResource$("); + }); +}); + +// ----------------------------------------------------------------------- +// Test 2: Import from @qwik.dev/core — import specifier renamed +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - import from @qwik.dev/core: specifier renamed", () => { + it("rewrites useResource$ to useAsync$ in import specifier from @qwik.dev/core", () => { + const source = `import { useResource$ } from "@qwik.dev/core"; +const res = useResource$(async ({ track }) => { + return await fetchData(); +});`; + const result = transform(source); + expect(result).toContain('import { useAsync$ } from "@qwik.dev/core"'); + // Call site rewritten (the TODO comment may contain "useResource$" as a string) + expect(result).not.toContain("= useResource$("); + }); +}); + +// ----------------------------------------------------------------------- +// Test 3: Import from @builder.io/qwik — also matched as import source +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - import from @builder.io/qwik: specifier renamed", () => { + it("rewrites useResource$ to useAsync$ in import specifier from @builder.io/qwik", () => { + const source = `import { useResource$ } from "@builder.io/qwik"; +const res = useResource$(async ({ track }) => { + return await fetchData(); +});`; + const result = transform(source); + expect(result).toContain('import { useAsync$ } from "@builder.io/qwik"'); + // Call site rewritten (the TODO comment may contain "useResource$" as a string) + expect(result).not.toContain("= useResource$("); + }); +}); + +// ----------------------------------------------------------------------- +// Test 4: TODO comment added about return type change +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - TODO comment: return type change noted", () => { + it("inserts TODO comment about ResourceReturn vs AsyncSignal before the call statement", () => { + const source = `const res = useResource$(async ({ track }) => { + return await fetchData(); +});`; + const result = transform(source); + expect(result).toContain("TODO:"); + expect(result).toContain("useAsync$"); + // TODO should appear before the call + const todoIdx = result.indexOf("TODO:"); + const callIdx = result.indexOf("useAsync$"); + expect(todoIdx).toBeLessThan(callIdx); + }); +}); + +// ----------------------------------------------------------------------- +// Test 5: Nested in component$ — deeply nested useResource$ is found +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - nested in component$: deep traversal finds the call", () => { + it("transforms useResource$ nested inside component$ callback", () => { + const source = `export default component$(() => { + const res = useResource$(async ({ track }) => { + return await loadData(); + }); +})`; + const result = transform(source); + expect(result).toContain("useAsync$(async ({ track }) => {"); + // Call site rewritten (the TODO comment may contain "useResource$" as a string) + expect(result).not.toContain("= useResource$("); + }); +}); + +// ----------------------------------------------------------------------- +// Test 6: Multiple useResource$ calls — all rewritten +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - multiple calls: all rewritten", () => { + it("rewrites all useResource$ call sites in one file", () => { + const source = `const res1 = useResource$(async ({ track }) => { + return await fetchFirst(); +}); +const res2 = useResource$(async ({ track, cleanup }) => { + return await fetchSecond(); +});`; + const result = transform(source); + // Call sites rewritten (the TODO comment may contain "useResource$" as a string) + expect(result).not.toContain("= useResource$("); + // Count only call sites (useAsync$ followed by '('), not TODO comment occurrences + const asyncCount = (result.match(/useAsync\$\(/g) || []).length; + expect(asyncCount).toBe(2); + }); +}); + +// ----------------------------------------------------------------------- +// Test 7: No useResource$ — returns empty replacements +// ----------------------------------------------------------------------- +describe("migrateUseResourceTransform - no useResource$: returns empty replacements", () => { + it("returns empty replacements when no useResource$ is present", () => { + const source = `const x = useTask$(async () => doWork());`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = migrateUseResourceTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); diff --git a/tests/unit/upgrade/pipeline-integration.spec.ts b/tests/unit/upgrade/pipeline-integration.spec.ts new file mode 100644 index 0000000..aed4439 --- /dev/null +++ b/tests/unit/upgrade/pipeline-integration.spec.ts @@ -0,0 +1,233 @@ +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock network-dependent steps BEFORE importing runV2Migration. +// vi.mock is hoisted to the top of the file by Vitest automatically. +vi.mock("../../../migrations/v2/versions.ts", () => ({ + resolveV2Versions: vi.fn().mockReturnValue({ + "@qwik.dev/core": "2.0.0", + "@qwik.dev/router": "2.0.0", + }), +})); + +vi.mock("../../../migrations/v2/update-dependencies.ts", () => ({ + checkTsMorphPreExisting: vi.fn().mockReturnValue(false), + removeTsMorphFromPackageJson: vi.fn(), + updateDependencies: vi.fn().mockResolvedValue(undefined), +})); + +import { runV2Migration } from "../../../migrations/v2/index.ts"; + +// Combined fixture: root.tsx with ALL migratable patterns in a single file. +// CRITICAL: Must use @builder.io/qwik-city (not @qwik.dev/router) because XFRM-04 +// runs at Step 2b — before Step 3 text replacements rename the package strings. +const COMBINED_ROOT_TSX = `import { QwikCityProvider } from "@builder.io/qwik-city"; +import { component$, useComputed$, useVisibleTask$ } from "@builder.io/qwik"; +import { useResource$ } from "@builder.io/qwik"; +import { usePreventNavigate } from "@builder.io/qwik-labs"; + +export default component$(() => { + const data = useComputed$(async () => await fetch("/api").then(r => r.json())); + const res = useResource$(async ({ track }) => { + track(() => data.value); + return await fetch("/data"); + }); + useVisibleTask$({ eagerness: "load" }, async () => { console.log("loaded"); }); + const navigate = usePreventNavigate(); + return ( + +
Hello
+
+ ); +}); +`; + +// Already-migrated fixture: uses @qwik.dev/* imports with no old patterns. +const ALREADY_MIGRATED_ROOT_TSX = `import { component$ } from "@qwik.dev/core"; +import { RouterOutlet, useQwikRouter } from "@qwik.dev/router"; + +export default component$(() => { + const router = useQwikRouter(); + return ( +
+ +
+ ); +}); +`; + +// Tests must run sequentially: runV2Migration calls process.chdir() which is a global +// side effect. Running tests in parallel in the same process would corrupt the cwd. +describe.sequential("runV2Migration - pipeline integration: all transforms compose correctly", () => { + let tmpDir: string; + + beforeEach(() => { + // realpathSync resolves macOS /var → /private/var symlink so that + // process.chdir() and relative() produce consistent paths in runV2Migration. + tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "qwik-pipeline-test-"))); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("applies all transforms in correct order on a combined fixture", async () => { + // Write .gitignore (needed for visitNotIgnoredFiles to work correctly) + writeFileSync(join(tmpDir, ".gitignore"), "dist/\nnode_modules/\n", "utf-8"); + + // Write package.json with @builder.io/qwik-city in dependencies (required for XFRM-04 + // to detect a Qwik Router project and apply QwikCityProvider transform). + // No "type": "module" here — CONF-03 should add it. + writeFileSync( + join(tmpDir, "package.json"), + JSON.stringify( + { + name: "test-v1-project", + dependencies: { + "@builder.io/qwik-city": "^1.9.0", + "@builder.io/qwik": "^1.9.0", + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + // Write tsconfig.json with old jsxImportSource and moduleResolution (triggers CONF-01/02) + writeFileSync( + join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + jsxImportSource: "@builder.io/qwik", + moduleResolution: "Node", + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + // Write src/root.tsx with all migratable patterns + mkdirSync(join(tmpDir, "src"), { recursive: true }); + writeFileSync(join(tmpDir, "src", "root.tsx"), COMBINED_ROOT_TSX, "utf-8"); + + // Run the full migration pipeline + await runV2Migration(tmpDir); + + const rootContent = readFileSync(join(tmpDir, "src", "root.tsx"), "utf-8"); + const tsconfigContent = readFileSync(join(tmpDir, "tsconfig.json"), "utf-8"); + const pkgContent = readFileSync(join(tmpDir, "package.json"), "utf-8"); + + // --- Import renames (RNME-01/02 via Step 2) --- + // @builder.io/qwik should be renamed to @qwik.dev/core + expect(rootContent).toContain("@qwik.dev/core"); + expect(rootContent).not.toContain('"@builder.io/qwik"'); + // @builder.io/qwik-city should be renamed to @qwik.dev/router (via Step 3 text replacement) + expect(rootContent).toContain("@qwik.dev/router"); + expect(rootContent).not.toContain('"@builder.io/qwik-city"'); + + // --- QwikCityProvider structural rewrite (XFRM-04 via Step 2b) --- + // QwikCityProvider JSX element removed + expect(rootContent).not.toContain(""); + expect(rootContent).not.toContain(""); + // useQwikRouter() hook injected + expect(rootContent).toContain("useQwikRouter()"); + + // --- useVisibleTask$ eagerness removal (XFRM-02 via Step 2b) --- + expect(rootContent).not.toContain("eagerness"); + + // --- useComputed$(async ...) rewritten to useAsync$ (XFRM-01 via Step 2b) --- + expect(rootContent).toContain("useAsync$"); + expect(rootContent).not.toContain("useComputed$"); + + // --- useResource$ rewritten to useAsync$ (XFRM-03 via Step 2b) --- + // useResource$ call site must be rewritten (TODO comment may contain the string) + expect(rootContent).not.toContain("= useResource$("); + + // --- @builder.io/qwik-labs migration (ECOS-01 via Step 2b) --- + // usePreventNavigate should be migrated to usePreventNavigate$ in @qwik.dev/router + expect(rootContent).not.toContain("@builder.io/qwik-labs"); + + // --- Config fixes --- + // CONF-01: jsxImportSource → @qwik.dev/core + expect(tsconfigContent).toContain("@qwik.dev/core"); + expect(tsconfigContent).not.toContain("@builder.io/qwik"); + // CONF-02: moduleResolution → Bundler + expect(tsconfigContent).toContain("Bundler"); + expect(tsconfigContent).not.toContain('"Node"'); + // CONF-03: package.json gets "type": "module" + expect(pkgContent).toContain('"type": "module"'); + }); + + it("does not modify files in an already-migrated project (idempotent)", async () => { + // Write .gitignore + writeFileSync(join(tmpDir, ".gitignore"), "dist/\nnode_modules/\n", "utf-8"); + + // Already-migrated package.json with no old patterns, already has type: "module" + writeFileSync( + join(tmpDir, "package.json"), + JSON.stringify( + { + name: "test-v2-project", + type: "module", + dependencies: { + "@qwik.dev/core": "^2.0.0", + "@qwik.dev/router": "^2.0.0", + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + // Already-migrated tsconfig.json + writeFileSync( + join(tmpDir, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + jsxImportSource: "@qwik.dev/core", + moduleResolution: "Bundler", + }, + }, + null, + 2, + ) + "\n", + "utf-8", + ); + + // Write src/root.tsx with already-migrated content + mkdirSync(join(tmpDir, "src"), { recursive: true }); + writeFileSync(join(tmpDir, "src", "root.tsx"), ALREADY_MIGRATED_ROOT_TSX, "utf-8"); + + // Run migration — should be a no-op on already-migrated project + await runV2Migration(tmpDir); + + const rootContent = readFileSync(join(tmpDir, "src", "root.tsx"), "utf-8"); + const tsconfigContent = readFileSync(join(tmpDir, "tsconfig.json"), "utf-8"); + const pkgContent = readFileSync(join(tmpDir, "package.json"), "utf-8"); + + // Already-migrated imports must still use @qwik.dev/* (no regressions) + expect(rootContent).toContain("@qwik.dev/core"); + expect(rootContent).toContain("@qwik.dev/router"); + expect(rootContent).not.toContain("@builder.io/"); + + // Config should remain correct + expect(tsconfigContent).toContain("@qwik.dev/core"); + expect(tsconfigContent).toContain("Bundler"); + expect(pkgContent).toContain('"type": "module"'); + + // No old patterns should appear + expect(rootContent).not.toContain("QwikCityProvider"); + expect(rootContent).not.toContain("eagerness"); + expect(rootContent).not.toContain("useComputed$"); + expect(rootContent).not.toContain("useResource$"); + expect(rootContent).not.toContain("@builder.io/qwik-labs"); + }); +}); diff --git a/tests/unit/upgrade/remove-eagerness.spec.ts b/tests/unit/upgrade/remove-eagerness.spec.ts new file mode 100644 index 0000000..0a89f20 --- /dev/null +++ b/tests/unit/upgrade/remove-eagerness.spec.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { parseSync } from "oxc-parser"; +import MagicString from "magic-string"; +import { removeEagernessTransform } from "../../../migrations/v2/transforms/remove-eagerness.ts"; +import type { SourceReplacement } from "../../../migrations/v2/types.ts"; + +/** + * Apply a list of SourceReplacements to a source string using MagicString. + * Mirrors the logic in applyTransforms — sort descending by start, then overwrite. + * This is inlined here for test isolation (no file I/O needed). + */ +function applyReplacements(source: string, replacements: SourceReplacement[]): string { + if (replacements.length === 0) return source; + const sorted = [...replacements].sort((a, b) => b.start - a.start); + const ms = new MagicString(source); + for (const { start, end, replacement } of sorted) { + ms.overwrite(start, end, replacement); + } + return ms.toString(); +} + +function transform(source: string): string { + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = removeEagernessTransform(filePath, source, parseResult); + return applyReplacements(source, replacements); +} + +// ----------------------------------------------------------------------- +// Behavior 1: Solo eagerness prop — entire first arg removed +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - solo eagerness: removes entire first argument", () => { + it("removes {eagerness: 'load'} and trailing comma+space when eagerness is only prop", () => { + const source = `useVisibleTask$({eagerness: 'load'}, async () => { console.log('hi') })`; + const result = transform(source); + expect(result).toBe(`useVisibleTask$(async () => { console.log('hi') })`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 2: Multi-prop, eagerness first — eagerness prop removed, rest kept +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - eagerness first among multiple props", () => { + it("removes eagerness when it is the first property, preserving remaining props", () => { + const source = `useVisibleTask$({eagerness: 'load', strategy: 'intersection'}, cb)`; + const result = transform(source); + expect(result).toBe(`useVisibleTask$({strategy: 'intersection'}, cb)`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 3: Multi-prop, eagerness last — eagerness prop removed, rest kept +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - eagerness last among multiple props", () => { + it("removes eagerness when it is the last property, preserving leading props", () => { + const source = `useVisibleTask$({strategy: 'intersection', eagerness: 'load'}, cb)`; + const result = transform(source); + expect(result).toBe(`useVisibleTask$({strategy: 'intersection'}, cb)`); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 4: No eagerness prop — not modified, returns empty replacements +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - no eagerness prop: file not modified", () => { + it("returns empty replacements when options object has no eagerness property", () => { + const source = `useVisibleTask$({strategy: 'intersection'}, cb)`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = removeEagernessTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 5: Single-arg form (no options object) — not modified +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - single-arg form: not modified", () => { + it("returns empty replacements when useVisibleTask$ has only one argument (callback)", () => { + const source = `useVisibleTask$(async () => { return 42; })`; + const filePath = "test.ts"; + const parseResult = parseSync(filePath, source, { sourceType: "module" }); + const replacements = removeEagernessTransform(filePath, source, parseResult); + expect(replacements).toHaveLength(0); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 6: Nested in component$ — deeply nested call is found and transformed +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - nested inside component$: deep traversal finds the call", () => { + it("transforms useVisibleTask$ nested at depth 6+ inside component$ callback", () => { + const source = `export default component$(() => { + useVisibleTask$({eagerness: 'load'}, async () => { + // some async work + }) +})`; + const result = transform(source); + expect(result).toContain("useVisibleTask$(async () => {"); + expect(result).not.toContain("eagerness"); + expect(result).not.toContain("{eagerness:"); + }); +}); + +// ----------------------------------------------------------------------- +// Behavior 7: Multiple calls in one file — both are transformed +// ----------------------------------------------------------------------- +describe("removeEagernessTransform - multiple calls: all eagerness props removed", () => { + it("transforms two separate useVisibleTask$ calls with eagerness in one file", () => { + const source = `export const A = component$(() => { + useVisibleTask$({eagerness: 'load'}, async () => { doA() }) + useVisibleTask$({eagerness: 'visible'}, async () => { doB() }) +})`; + const result = transform(source); + expect(result).not.toContain("eagerness"); + expect(result).toContain("useVisibleTask$(async () => { doA() })"); + expect(result).toContain("useVisibleTask$(async () => { doB() })"); + }); +}); diff --git a/tests/unit/upgrade/rename-import.spec.ts b/tests/unit/upgrade/rename-import.spec.ts new file mode 100644 index 0000000..a6620eb --- /dev/null +++ b/tests/unit/upgrade/rename-import.spec.ts @@ -0,0 +1,138 @@ +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { + IMPORT_RENAME_ROUNDS, + replaceImportInFiles, +} from "../../../migrations/v2/rename-import.ts"; + +// Helper to create a temp directory and files for testing +function withTempDir(callback: (dir: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), "rename-import-test-")); + try { + callback(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("replaceImportInFiles - RNME-01 (QwikCityMockProvider → QwikRouterMockProvider)", () => { + it("renames QwikCityMockProvider to QwikRouterMockProvider in a file importing from @builder.io/qwik-city", () => { + withTempDir((dir) => { + const filePath = join(dir, "test.tsx"); + writeFileSync( + filePath, + `import { QwikCityMockProvider } from "@builder.io/qwik-city";\nexport default function App() {}`, + ); + + replaceImportInFiles( + [["QwikCityMockProvider", "QwikRouterMockProvider"]], + "@builder.io/qwik-city", + [filePath], + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain("QwikRouterMockProvider"); + expect(result).not.toContain("QwikCityMockProvider"); + expect(result).toContain('@builder.io/qwik-city"'); + }); + }); +}); + +describe("replaceImportInFiles - RNME-02 (QwikCityProps → QwikRouterProps)", () => { + it("renames QwikCityProps to QwikRouterProps in a file importing from @builder.io/qwik-city", () => { + withTempDir((dir) => { + const filePath = join(dir, "test.tsx"); + writeFileSync( + filePath, + `import { QwikCityProps } from "@builder.io/qwik-city";\nexport default function App() {}`, + ); + + replaceImportInFiles([["QwikCityProps", "QwikRouterProps"]], "@builder.io/qwik-city", [ + filePath, + ]); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain("QwikRouterProps"); + expect(result).not.toContain("QwikCityProps"); + expect(result).toContain('@builder.io/qwik-city"'); + }); + }); +}); + +describe("replaceImportInFiles - combined renames", () => { + it("renames both QwikCityMockProvider and QwikCityProps in the same file", () => { + withTempDir((dir) => { + const filePath = join(dir, "test.tsx"); + writeFileSync( + filePath, + `import { QwikCityMockProvider, QwikCityProps } from "@builder.io/qwik-city";\nexport default function App() {}`, + ); + + replaceImportInFiles( + [ + ["QwikCityMockProvider", "QwikRouterMockProvider"], + ["QwikCityProps", "QwikRouterProps"], + ], + "@builder.io/qwik-city", + [filePath], + ); + + const result = readFileSync(filePath, "utf-8"); + expect(result).toContain("QwikRouterMockProvider"); + expect(result).toContain("QwikRouterProps"); + expect(result).not.toContain("QwikCityMockProvider"); + expect(result).not.toContain("QwikCityProps"); + }); + }); +}); + +describe("replaceImportInFiles - aliased imports", () => { + it("renames the imported name but preserves alias for aliased imports", () => { + withTempDir((dir) => { + const filePath = join(dir, "test.tsx"); + writeFileSync( + filePath, + `import { QwikCityMockProvider as Mock } from "@builder.io/qwik-city";\nexport default function App() { return ; }`, + ); + + replaceImportInFiles( + [["QwikCityMockProvider", "QwikRouterMockProvider"]], + "@builder.io/qwik-city", + [filePath], + ); + + const result = readFileSync(filePath, "utf-8"); + // The imported name (left side of "as") should be renamed + expect(result).toContain("QwikRouterMockProvider as Mock"); + // The original name should be gone from the import specifier + expect(result).not.toContain("QwikCityMockProvider as Mock"); + // The alias "Mock" should still be used in JSX + expect(result).toContain(""); + }); + }); +}); + +describe("IMPORT_RENAME_ROUNDS Round 1", () => { + it("has exactly 4 entries in Round 1 changes (2 existing + RNME-01 + RNME-02; QwikCityProvider handled by XFRM-04)", () => { + const round1 = IMPORT_RENAME_ROUNDS[0]; + expect(round1).toBeDefined(); + expect(round1!.library).toBe("@builder.io/qwik-city"); + expect(round1!.changes).toHaveLength(4); + }); + + it("Round 1 includes QwikCityMockProvider rename (RNME-01)", () => { + const round1 = IMPORT_RENAME_ROUNDS[0]!; + const entry = round1.changes.find(([old]) => old === "QwikCityMockProvider"); + expect(entry).toBeDefined(); + expect(entry![1]).toBe("QwikRouterMockProvider"); + }); + + it("Round 1 includes QwikCityProps rename (RNME-02)", () => { + const round1 = IMPORT_RENAME_ROUNDS[0]!; + const entry = round1.changes.find(([old]) => old === "QwikCityProps"); + expect(entry).toBeDefined(); + expect(entry![1]).toBe("QwikRouterProps"); + }); +});