diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4952b14c49..bfa07caed6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,10 +22,12 @@ on: branches: [main] push: branches: [main] + schedule: + - cron: '43 9 * * *' workflow_dispatch: concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: @@ -63,7 +65,7 @@ jobs: # PR checks may predate later main changes, so the planning lane also # validates the exact merged delta. Dispatches and unavailable history # fail safe to every surface. - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]] || [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then + if [[ "${{ github.event_name }}" == "workflow_dispatch" || "${{ github.event_name }}" == "schedule" ]] || [[ "$BASE_SHA" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}"; then node scripts/ci-test-plan.mjs --full >> "$GITHUB_OUTPUT" else node scripts/ci-test-plan.mjs --base "$BASE_SHA" --head "$HEAD_SHA" >> "$GITHUB_OUTPUT" @@ -174,6 +176,10 @@ jobs: if: steps.plan.outputs.code == 'true' || steps.plan.outputs.astryx_surface == 'true' || steps.plan.outputs.asf_source == 'true' || steps.plan.outputs.cli_package == 'true' || steps.plan.outputs.release_contract == 'true' run: npm ci + - name: Test Desktop e2e selection + if: steps.plan.outputs.e2e == 'true' + run: node --test scripts/desktop-e2e-test-selection.test.mjs + - name: Check localized TUI copy boundaries if: steps.plan.outputs.code == 'true' run: | @@ -336,8 +342,23 @@ jobs: - name: Desktop e2e if: steps.plan.outputs.e2e == 'true' + env: + BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event_name == 'push' && github.sha || github.event.pull_request.head.sha }} run: | set -euo pipefail + spec_list="$RUNNER_TEMP/desktop-e2e-specs.txt" + if [[ "${{ steps.plan.outputs.full }}" == "true" ]]; then + node scripts/desktop-e2e-test-selection.mjs --full > "$spec_list" + else + node scripts/desktop-e2e-test-selection.mjs \ + --base "$BASE_SHA" --head "$HEAD_SHA" > "$spec_list" + fi + mapfile -t specs < "$spec_list" + if (( ${#specs[@]} == 0 )); then + echo "No Desktop e2e spec is reachable from this change" + exit 0 + fi display_base=90 worker_count=4 xvfb_pids=() @@ -366,7 +387,7 @@ jobs: done MAKA_E2E_X_DISPLAY_BASE="$display_base" \ npm exec -w @maka/desktop -- playwright test \ - --config e2e/playwright.config.ts --workers="$worker_count" + --config e2e/playwright.config.ts --workers="$worker_count" "${specs[@]}" - name: Browser WebContentsView semantic smoke if: steps.plan.outputs.e2e == 'true' diff --git a/apps/desktop/e2e/playwright.config.ts b/apps/desktop/e2e/playwright.config.ts index 00f23ac63d..6f4357d550 100644 --- a/apps/desktop/e2e/playwright.config.ts +++ b/apps/desktop/e2e/playwright.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ testDir: '.', fullyParallel: true, workers: 1, + reporter: 'list', // CI publishes no Playwright report that consumes Git metadata. Disable its // best-effort shallow-history fetch, which otherwise waits on a fixed timeout. captureGitInfo: { commit: false, diff: false }, diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index a1acf8d6fe..7becb2d610 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -88,6 +88,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/windows-upgrade-baseline.json', 'scripts/windows-package-source-closure.mjs', 'scripts/windows-package-source-closure.test.mjs', + 'scripts/workspace-source-closure.mjs', // Reads the filter that closure test compares against, and `check:release` // is the only gate that runs it against `release-windows-check.yml`. 'scripts/workflow-pull-request-paths.mjs', @@ -204,6 +205,11 @@ const E2E_DRIVING_SCRIPTS = new Set([ 'apps/desktop/scripts/browser-observe-act-smoke.mjs', 'scripts/audit-alignment.mjs', 'scripts/ax-tree-audit.mjs', + 'scripts/desktop-e2e-test-selection.mjs', + 'scripts/desktop-e2e-test-selection.test.mjs', + 'scripts/electron-lifecycle.mjs', + 'scripts/fixture-env.mjs', + 'scripts/workspace-source-closure.mjs', ]); // Scripts / paths that can break the built Storybook catalog. Product stories @@ -542,6 +548,7 @@ export function formatGitHubOutputs(plan) { `cli_package=${plan.cliPackage}`, `code=${plan.code}`, `e2e=${plan.e2e}`, + `full=${plan.full}`, `runtime_host=${plan.runtimeHost}`, `runtime_sandbox=${plan.runtimeSandbox}`, `release_contract=${plan.releaseContract}`, diff --git a/scripts/ci-workflow-policy.test.mjs b/scripts/ci-workflow-policy.test.mjs index f118a99863..bb9a087b78 100644 --- a/scripts/ci-workflow-policy.test.mjs +++ b/scripts/ci-workflow-policy.test.mjs @@ -85,11 +85,20 @@ test('planning runs first and every later step gates on its outputs', () => { ); }); -test('core CI validates pull requests and the resulting main branch state', () => { +test('core CI validates pull requests, the resulting main branch state, and a nightly full run', () => { const workflow = readWorkflow('ci.yml'); assert.match(workflow, /pull_request:\n\s+branches: \[main\]/u); assert.match(workflow, /push:\n\s+branches: \[main\]/u); + assert.match(workflow, /schedule:\n\s+- cron:/u); + assert.match( + workflow, + /group: ci-\$\{\{ github\.workflow \}\}-\$\{\{ github\.event_name \}\}-\$\{\{ github\.ref \}\}/u, + ); + assert.match( + workflow, + /github\.event_name \}\}" == "workflow_dispatch" \|\| "\$\{\{ github\.event_name \}\}" == "schedule"/u, + ); assert.match( workflow, /BASE_SHA: \$\{\{ github\.event_name == 'push' && github\.event\.before \|\| github\.event\.pull_request\.base\.sha \}\}/u, @@ -99,6 +108,18 @@ test('core CI validates pull requests and the resulting main branch state', () = /HEAD_SHA: \$\{\{ github\.event_name == 'push' && github\.sha \|\| github\.event\.pull_request\.head\.sha \}\}/u, ); assert.match(workflow, /\[\[ "\$BASE_SHA" =~ \^0\+\$ \]\]/u); + assert.match( + workflow, + /if \[\[ "\$\{\{ steps\.plan\.outputs\.full \}\}" == "true" \]\]; then\n\s+node scripts\/desktop-e2e-test-selection\.mjs --full/u, + ); + assert.match( + workflow, + /mapfile -t specs < "\$spec_list"\n\s+if \(\( \$\{#specs\[@\]\} == 0 \)\); then\n\s+echo "No Desktop e2e spec is reachable from this change"\n\s+exit 0\n\s+fi\n\s+display_base=90/u, + ); + assert.match( + workflow, + /--config e2e\/playwright\.config\.ts --workers="\$worker_count" "\$\{specs\[@\]\}"/u, + ); }); test('core CI uses the Windows inventory package-script authority', () => { diff --git a/scripts/desktop-e2e-test-selection.mjs b/scripts/desktop-e2e-test-selection.mjs new file mode 100644 index 0000000000..c73c9a9521 --- /dev/null +++ b/scripts/desktop-e2e-test-selection.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readdirSync } from 'node:fs'; +import { dirname, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { changedFilesBetween, planTests } from './ci-test-plan.mjs'; +import { collectWorkspaceSourceClosure } from './workspace-source-closure.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const defaultRepoRoot = dirname(dirname(scriptPath)); +const desktopE2eRoot = 'apps/desktop/e2e'; + +const SELECTION_AUTHORITY_FILES = new Set([ + 'apps/desktop/e2e/playwright.config.ts', + 'scripts/desktop-e2e-test-selection.mjs', + 'scripts/workspace-source-closure.mjs', +]); + +function normalizePath(path) { + return path.split(sep).join('/').replace(/^\.\//u, ''); +} + +export function listDesktopE2eSpecs(repoRoot = defaultRepoRoot) { + const root = resolve(repoRoot, desktopE2eRoot); + return readdirSync(root, { recursive: true }) + .filter((path) => path.endsWith('.spec.ts')) + .map((path) => normalizePath(relative(repoRoot, resolve(root, path)))) + .sort(); +} + +export async function collectDesktopE2eSpecClosures(specs, repoRoot = defaultRepoRoot) { + const closures = new Map(); + for (const spec of specs) { + closures.set(spec, new Set(await collectWorkspaceSourceClosure([spec], repoRoot))); + } + return closures; +} + +export async function selectDesktopE2eSpecs(changedFiles, options = {}) { + const repoRoot = options.repoRoot ?? defaultRepoRoot; + const files = [...new Set(changedFiles.map(normalizePath).filter(Boolean))]; + const specs = options.specs ?? listDesktopE2eSpecs(repoRoot); + const plan = planTests(files, { repoRoot, forceFull: options.forceFull }); + if (!plan.e2e) return []; + if (plan.full || files.some((path) => SELECTION_AUTHORITY_FILES.has(path))) return specs; + + const closures = options.closures ?? (await collectDesktopE2eSpecClosures(specs, repoRoot)); + return specs.filter((spec) => files.some((path) => closures.get(spec)?.has(path))); +} + +function parseArgs(args) { + const parsed = { base: undefined, forceFull: false, head: undefined }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--full') parsed.forceFull = true; + else if (arg === '--base') parsed.base = args[++index]; + else if (arg === '--head') parsed.head = args[++index]; + else throw new Error(`Unknown argument: ${arg}`); + } + if (!parsed.forceFull && (!parsed.base || !parsed.head)) { + throw new Error('Expected --full or both --base and --head '); + } + return parsed; +} + +async function main(args) { + const parsed = parseArgs(args); + const changedFiles = parsed.forceFull ? [] : changedFilesBetween(parsed.base, parsed.head); + const specs = await selectDesktopE2eSpecs(changedFiles, { forceFull: parsed.forceFull }); + const workspaceSpecs = specs.map((path) => path.slice('apps/desktop/'.length)); + process.stdout.write(workspaceSpecs.length > 0 ? `${workspaceSpecs.join('\n')}\n` : ''); + process.stderr.write(`Desktop e2e selection: ${specs.length} spec files\n`); +} + +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + try { + await main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 2; + } +} diff --git a/scripts/desktop-e2e-test-selection.test.mjs b/scripts/desktop-e2e-test-selection.test.mjs new file mode 100644 index 0000000000..9aad2484bd --- /dev/null +++ b/scripts/desktop-e2e-test-selection.test.mjs @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + collectDesktopE2eSpecClosures, + listDesktopE2eSpecs, + selectDesktopE2eSpecs, +} from './desktop-e2e-test-selection.mjs'; + +const specs = ['apps/desktop/e2e/alpha.spec.ts', 'apps/desktop/e2e/beta.spec.ts']; +const closures = new Map([ + [specs[0], new Set([specs[0], 'apps/desktop/e2e/fixtures.ts', 'apps/desktop/src/main/a.ts'])], + [specs[1], new Set([specs[1], 'apps/desktop/e2e/fixtures.ts', 'apps/desktop/src/main/b.ts'])], +]); + +test('selection follows the exact source closure of each spec', async () => { + assert.deepEqual( + await selectDesktopE2eSpecs(['apps/desktop/src/main/a.ts'], { specs, closures }), + [specs[0]], + ); + assert.deepEqual( + await selectDesktopE2eSpecs(['apps/desktop/e2e/fixtures.ts'], { specs, closures }), + specs, + ); + assert.deepEqual( + await selectDesktopE2eSpecs(['apps/desktop/src/main/unreached.ts'], { specs, closures }), + [], + ); +}); + +test('full plans and selection authorities run every spec', async () => { + for (const path of [ + 'package-lock.json', + 'apps/desktop/e2e/playwright.config.ts', + 'scripts/desktop-e2e-test-selection.mjs', + 'scripts/workspace-source-closure.mjs', + ]) { + assert.deepEqual(await selectDesktopE2eSpecs([path], { specs, closures }), specs, path); + } +}); + +test('non-Electron changes select no Desktop spec', async () => { + assert.deepEqual( + await selectDesktopE2eSpecs(['packages/runtime/src/runtime.ts'], { specs, closures }), + [], + ); +}); + +test('current spec closures select every repository script they import', async () => { + const currentSpecs = listDesktopE2eSpecs(); + const currentClosures = await collectDesktopE2eSpecClosures(currentSpecs); + + assert.ok(currentSpecs.length > 0); + for (const spec of currentSpecs) { + assert.ok(currentClosures.get(spec)?.has(spec), spec); + assert.ok(currentClosures.get(spec)?.has('apps/desktop/e2e/fixtures.ts'), spec); + } + + const scriptInputs = [ + ...new Set( + [...currentClosures.values()].flatMap((closure) => + [...closure].filter((path) => path.startsWith('scripts/')), + ), + ), + ].sort(); + assert.ok(scriptInputs.length > 0); + for (const path of scriptInputs) { + const reachableSpecs = currentSpecs.filter((spec) => currentClosures.get(spec)?.has(path)); + assert.deepEqual( + await selectDesktopE2eSpecs([path], { + specs: currentSpecs, + closures: currentClosures, + }), + reachableSpecs, + path, + ); + } + assert.deepEqual( + await selectDesktopE2eSpecs(['scripts/ax-tree-audit.mjs'], { + specs: currentSpecs, + closures: currentClosures, + }), + ['apps/desktop/e2e/accessibility-coverage.spec.ts'], + ); +}); diff --git a/scripts/windows-package-source-closure.mjs b/scripts/windows-package-source-closure.mjs index 4e546e79b7..0818e876d0 100644 --- a/scripts/windows-package-source-closure.mjs +++ b/scripts/windows-package-source-closure.mjs @@ -17,124 +17,13 @@ * under the License. */ -import { existsSync, readFileSync } from 'node:fs'; -import { extname, join, relative, resolve, sep } from 'node:path'; -import { build } from 'esbuild'; - -const defaultRepoRoot = resolve(import.meta.dirname, '..'); -const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json']; - export const windowsPackageSourceEntrypoints = [ 'packages/runtime/src/filesystem-worker/index.ts', 'packages/runtime/src/filesystem-worker/worker-entry.ts', 'packages/runtime/src/sandbox/index.ts', ]; -/** - * Workspace sources `entryPoints` reach, transitively, resolved through the - * workspace `exports` map back to `src` rather than to built `dist`. Two lanes - * derive a path filter from this instead of maintaining one by hand, so a - * dependency added anywhere under an entry point cannot escape the filter that - * is supposed to schedule the runner able to observe it. - * - * Static imports only, which is the limit of what "generated, not curated" - * buys here: a process boundary is invisible to it. `root-authority.test.ts` - * forks `fixtures/root-initialization-race.js` and - * `filesystem-worker/worker-entry.ts` is bundled rather than imported, so - * neither appears in a closure that starts from them. Both are free of `win32` - * today, which is why the filters derived from this are complete — not because - * the derivation guarantees it. - */ -export async function collectWorkspaceSourceClosure(entryPoints, repoRoot = defaultRepoRoot) { - const workspaces = loadWorkspacePackages(repoRoot); - const result = await build({ - absWorkingDir: repoRoot, - bundle: true, - entryPoints, - format: 'esm', - logLevel: 'silent', - metafile: true, - outdir: 'workspace-source-closure', - packages: 'external', - platform: 'node', - plugins: [workspaceSourcePlugin(repoRoot, workspaces)], - treeShaking: false, - write: false, - }); - return Object.keys(result.metafile.inputs) - .map(normalize) - .filter((path) => path.startsWith('packages/')) - .sort(); -} - export function windowsReleasePatternCoversSource(path, pattern) { if (path === pattern) return true; return pattern.endsWith('/**') && path.startsWith(pattern.slice(0, -2)); } - -function workspaceSourcePlugin(repoRoot, workspaces) { - return { - name: 'maka-workspace-source', - setup(esbuild) { - esbuild.onResolve({ filter: /^@maka\// }, ({ path: specifier }) => { - const workspace = [...workspaces.values()].find( - ({ name }) => specifier === name || specifier.startsWith(`${name}/`), - ); - if (!workspace) return undefined; - const subpath = - specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`; - const exported = exportTarget(workspace.manifest.exports?.[subpath]); - const candidate = exported - ? resolve(workspace.directory, exported.replace(/^\.\/dist\//u, './src/')) - : resolve(workspace.directory, 'src', specifier.slice(workspace.name.length + 1)); - return { path: resolveSourceCandidate(candidate, repoRoot, specifier) }; - }); - }, - }; -} - -function resolveSourceCandidate(candidate, repoRoot, specifier) { - const extension = extname(candidate); - const candidates = [candidate]; - if (['.js', '.mjs', '.cjs'].includes(extension)) { - const stem = candidate.slice(0, -extension.length); - candidates.push(...sourceExtensions.map((sourceExtension) => `${stem}${sourceExtension}`)); - } else if (!extension) { - candidates.push(...sourceExtensions.map((sourceExtension) => `${candidate}${sourceExtension}`)); - candidates.push( - ...sourceExtensions.map((sourceExtension) => join(candidate, `index${sourceExtension}`)), - ); - } - const resolved = candidates.find((path) => existsSync(path)); - if (!resolved || !normalize(relative(repoRoot, resolved)).startsWith('packages/')) { - throw new Error( - `Unable to resolve local Windows package import ${specifier} from ${candidate}`, - ); - } - return resolved; -} - -function exportTarget(value) { - if (typeof value === 'string') return value; - if (!value || typeof value !== 'object') return undefined; - for (const condition of ['types', 'import', 'default']) { - const target = exportTarget(value[condition]); - if (target) return target; - } - return undefined; -} - -function loadWorkspacePackages(repoRoot) { - const rootManifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); - const result = new Map(); - for (const workspacePath of rootManifest.workspaces ?? []) { - const directory = resolve(repoRoot, workspacePath); - const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); - result.set(manifest.name, { directory, manifest, name: manifest.name }); - } - return result; -} - -function normalize(path) { - return path.split(sep).join('/'); -} diff --git a/scripts/windows-package-source-closure.test.mjs b/scripts/windows-package-source-closure.test.mjs index 8af252ca14..8024888cd4 100644 --- a/scripts/windows-package-source-closure.test.mjs +++ b/scripts/windows-package-source-closure.test.mjs @@ -21,10 +21,10 @@ import assert from 'node:assert/strict'; import { existsSync, readFileSync } from 'node:fs'; import test from 'node:test'; import { - collectWorkspaceSourceClosure, windowsPackageSourceEntrypoints, windowsReleasePatternCoversSource, } from './windows-package-source-closure.mjs'; +import { collectWorkspaceSourceClosure } from './workspace-source-closure.mjs'; import { readPullRequestPathFilter } from './workflow-pull-request-paths.mjs'; /** diff --git a/scripts/workspace-source-closure.mjs b/scripts/workspace-source-closure.mjs new file mode 100644 index 0000000000..40b65870e0 --- /dev/null +++ b/scripts/workspace-source-closure.mjs @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { extname, join, relative, resolve, sep } from 'node:path'; +import { build } from 'esbuild'; + +const defaultRepoRoot = resolve(import.meta.dirname, '..'); +const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json']; + +/** + * Repository sources reachable transitively from `entryPoints`. Workspace + * package exports resolve to `src`; external dependencies stay outside the + * graph. Callers derive test ownership and path filters from the same imports + * the source consumes. + * + * This closure covers static imports. Spawned processes and generated or + * separately bundled entry points need caller-owned escape coverage. + */ +export async function collectWorkspaceSourceClosure(entryPoints, repoRoot = defaultRepoRoot) { + const workspaces = loadWorkspacePackages(repoRoot); + const result = await build({ + absWorkingDir: repoRoot, + bundle: true, + entryPoints, + format: 'esm', + logLevel: 'silent', + metafile: true, + outdir: 'workspace-source-closure', + packages: 'external', + platform: 'node', + plugins: [workspaceSourcePlugin(repoRoot, workspaces)], + treeShaking: false, + write: false, + }); + return Object.keys(result.metafile.inputs).map(normalize).sort(); +} + +function workspaceSourcePlugin(repoRoot, workspaces) { + return { + name: 'maka-workspace-source', + setup(esbuild) { + esbuild.onResolve({ filter: /^@maka\// }, ({ path: specifier }) => { + const workspace = [...workspaces.values()].find( + ({ name }) => specifier === name || specifier.startsWith(`${name}/`), + ); + if (!workspace) return undefined; + const subpath = + specifier === workspace.name ? '.' : `.${specifier.slice(workspace.name.length)}`; + const exported = exportTarget(workspace.manifest.exports?.[subpath]); + const candidate = exported + ? resolve(workspace.directory, exported.replace(/^\.\/dist\//u, './src/')) + : resolve(workspace.directory, 'src', specifier.slice(workspace.name.length + 1)); + return { path: resolveSourceCandidate(candidate, repoRoot, specifier) }; + }); + }, + }; +} + +function resolveSourceCandidate(candidate, repoRoot, specifier) { + const extension = extname(candidate); + const candidates = [candidate]; + if (['.js', '.mjs', '.cjs'].includes(extension)) { + const stem = candidate.slice(0, -extension.length); + candidates.push(...sourceExtensions.map((sourceExtension) => `${stem}${sourceExtension}`)); + } else if (!extension) { + candidates.push(...sourceExtensions.map((sourceExtension) => `${candidate}${sourceExtension}`)); + candidates.push( + ...sourceExtensions.map((sourceExtension) => join(candidate, `index${sourceExtension}`)), + ); + } + const resolved = candidates.find((path) => existsSync(path)); + if (!resolved || !normalize(relative(repoRoot, resolved)).startsWith('packages/')) { + throw new Error(`Unable to resolve local workspace import ${specifier} from ${candidate}`); + } + return resolved; +} + +function exportTarget(value) { + if (typeof value === 'string') return value; + if (!value || typeof value !== 'object') return undefined; + for (const condition of ['types', 'import', 'default']) { + const target = exportTarget(value[condition]); + if (target) return target; + } + return undefined; +} + +function loadWorkspacePackages(repoRoot) { + const rootManifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); + const result = new Map(); + for (const workspacePath of rootManifest.workspaces ?? []) { + const directory = resolve(repoRoot, workspacePath); + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')); + result.set(manifest.name, { directory, manifest, name: manifest.name }); + } + return result; +} + +function normalize(path) { + return path.split(sep).join('/'); +}