diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59c0bfc..7617202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,13 +85,17 @@ jobs: e2e-relevant: - 'src/services/**' - 'src/logic/sync-manager.ts' + - 'src/logic/sync/**' + - 'src/logic/source-control/**' - 'src/utils/git-blob-sha.ts' - 'src/utils/path.ts' - 'src/utils/symlink.ts' - - 'e2e/**' + - 'e2e-tests/**' + - 'vitest.e2e.config.ts' - 'scripts/e2e-harness.sh' - 'scripts/e2e-namespace.sh' - 'scripts/e2e-namespace-cleanup.sh' + - 'scripts/e2e-suites.txt' - 'scripts/run-e2e.sh' - 'package.json' - 'package-lock.json' diff --git a/docs/obsidian-scanner-audit.md b/docs/obsidian-scanner-audit.md index 632649d..6a380cc 100644 --- a/docs/obsidian-scanner-audit.md +++ b/docs/obsidian-scanner-audit.md @@ -40,13 +40,13 @@ after the normal 1.5.7 release workflow completes. ## Phase 1 re-audit (Shell/Git E2E harness) Real-provider E2E returned in `e2e/**` (test/real-provider-e2e), rebuilt so none of the -previously-flagged APIs are used in any committed `.ts` file, regardless of directory — -`scripts/e2e-harness.sh` (Shell, not TypeScript) now owns branch/container lifecycle and git -authentication, and everything Node-only the suites still need at runtime (the real `requestUrl` -shim, the `window` timer alias, a git-CLI-backed verifier) is generated by that script into -`$E2E_RUNTIME_DIR` per run, never committed. See `docs/testing/real-provider-e2e.md`. +previously-flagged APIs are used in any committed `.ts` file — `scripts/e2e-harness.sh` (Shell, +not TypeScript) owned branch/container lifecycle and git authentication, and everything Node-only +the suites needed at runtime (the real `requestUrl` shim, the `window` timer alias, a +git-CLI-backed verifier) was generated by that script into `$E2E_RUNTIME_DIR` per run, never +committed. -Same grep-based method as the baseline above, re-run against the current tree: +Same grep-based method as the baseline above, re-run against that tree: | Check | Result | | --- | --- | @@ -56,13 +56,36 @@ Same grep-based method as the baseline above, re-run against the current tree: | Bare `setTimeout`/`setInterval` (not `window.*`) in `e2e/**`/`src/**` | None | | Unnecessary `as string` assertions in `e2e/config/env.ts` | Fixed — replaced with `requiredEnv()`, which throws instead of asserting | -`e2e/**/*.ts` is back in `tsconfig.json`'s `include` and in `eslint.config.mts`'s scope -(`npx eslint .` — 0 errors; `tsc -noEmit -skipLibCheck` — clean), since neither tool needs the -harness to have run first: the only imports of generated (not-yet-existing-at-typecheck-time) -files are runtime-computed dynamic `import()` calls, which `tsc` doesn't attempt to statically -resolve. +## Phase 2 re-audit (e2e-tests/ boundary, static runtime files) -The actual official scanner rescan against this harness is still outstanding from this checkout -(no access to the submission tooling here) — this section is the best available self-check in -the meantime, per the task's own acknowledgment that the real validation is a separate, -later step. +The harness moved to `e2e-tests/provider/**`, and the previously-generated runtime files +(`obsidian-request-url.ts`, `window-timers.ts`, `git-verifier.ts`) are now **committed** static +`.ts` files under `e2e-tests/provider/runtime/` and `e2e-tests/provider/support/`, on the premise +that the scanner's flagging is scoped to a submission's declared plugin surface +(`manifest.json`/`main.js`), not a blanket repo-wide grep. + +**This premise is unverified.** Phase 1's own removal was prompted by the baseline finding above, +which recorded these exact APIs being flagged while committed under `e2e/**` — a differently-named +directory, not a different scoping mechanism. Re-committing them under `e2e-tests/**` instead of +`e2e/**` changes the directory name but not, as far as this repo's own audit trail shows, the +thing the scanner actually keys on. No official rescan has been run against this change from this +checkout to confirm or refute that. + +Grep-based self-check against the current tree (same method as before, informational only — it +was already passing under the generated-runtime design too, so it does not distinguish the two): + +| Check | Result | +| --- | --- | +| `fetch(` in `src/**` | None | +| `globalThis` in `src/**` | None | +| `node:crypto`/`node:child_process`/`node:util` in `src/**` | None | +| `fetch`/`globalThis`/`node:child_process` in `e2e-tests/**` | Present (by design — see above) | + +`e2e-tests/**/*.ts` is in `tsconfig.json`'s `include` and in `eslint.config.mts`'s scope +(`npx eslint .` — 0 errors; `tsc -noEmit -skipLibCheck` — clean); unlike Phase 1, these are real +committed files, not ambient-module stand-ins for a generated target. + +**Follow-up required**: get an actual official scanner rescan against this directory structure +before relying on it. If it reproduces the Phase 1 finding, revert to per-run generation into an +uncommitted directory (git history has the Phase 1 implementation) rather than trying a third +directory name. diff --git a/docs/test-coverage.md b/docs/test-coverage.md index b67df82..54c9109 100644 --- a/docs/test-coverage.md +++ b/docs/test-coverage.md @@ -2,9 +2,11 @@ All tests are in `tests/` and run with `npm run test` (Vitest). -## Temporary E2E status +## Real-provider E2E -Real-provider E2E source has been temporarily removed from the plugin repository because the Obsidian official scanner treats Node-only E2E tooling as plugin source. The long-term E2E architecture is being evaluated separately. +Real-provider E2E (GitHub/GitLab/Gitea, against a real Git server) lives under +`e2e-tests/provider/`, separate from the unit tests in `tests/`. See +`docs/testing/real-provider-e2e.md` and `docs/obsidian-scanner-audit.md`. --- diff --git a/docs/testing/real-provider-e2e.md b/docs/testing/real-provider-e2e.md index cac80a1..1467022 100644 --- a/docs/testing/real-provider-e2e.md +++ b/docs/testing/real-provider-e2e.md @@ -23,28 +23,31 @@ GitHub Actions This replaced an earlier Node-based harness (`e2e/provision`, `e2e/verifier`, `e2e/providers`, `e2e/shim`, `scripts/run-e2e*.mjs`) that used `fetch`/`node:child_process`/`node:crypto` directly -in committed `.ts` files. The Obsidian community-plugin scanner flags those APIs wherever they -appear in the repo, regardless of directory — it doesn't matter that E2E code never ships in -`main.js`. See `docs/obsidian-scanner-audit.md`. - -**The fix isn't "move it to a differently-named folder"** — it's that no committed `.ts` file -uses those APIs at all: - -- `scripts/e2e-harness.sh` (Shell, not TypeScript) owns branch/container lifecycle: creating the - isolated test branch via plain `git push :refs/heads/` (no REST branch-creation - calls except the one GitLab numeric-project-ID resolution git genuinely can't do), and the - Gitea Docker container lifecycle via the `docker` CLI directly — never - `node:child_process`. -- Everything Node-only that the suites still need at runtime (the real `requestUrl` shim - production services import from `obsidian`, the `window.setTimeout` alias, and a small - git-CLI-backed verifier) is **generated fresh per run** by `scripts/e2e-harness.sh provision` - into `$E2E_RUNTIME_DIR`, not committed. Suites import these statically — `obsidian` and - `@e2e-runtime/git-verifier` are both resolved via `vitest.e2e.config.ts`'s `alias` map to - `$E2E_RUNTIME_DIR` at E2E runtime only. `e2e/runtime-modules.d.ts` gives - `@e2e-runtime/git-verifier` a compile-time shape (backed by `e2e/verifier-runtime-types.ts`) - so `npm run build`'s typecheck never needs the generated files to exist — no committed - suite ever does a runtime-computed dynamic `import()`, so there's nothing scanner-visible - for the Obsidian security lint rules to flag. +in committed `.ts` files. The Obsidian community-plugin scanner flagged those APIs in that +harness's committed files. See `docs/obsidian-scanner-audit.md`. + +`scripts/e2e-harness.sh` (Shell, not TypeScript) owns branch/container lifecycle: creating the +isolated test branch via plain `git push :refs/heads/` (no REST branch-creation +calls except the one GitLab numeric-project-ID resolution git genuinely can't do), and the +Gitea Docker container lifecycle via the `docker` CLI directly — never `node:child_process`. + +Everything Node-only the suites need at runtime (the real `requestUrl` shim production services +import from `obsidian`, the `window.setTimeout` alias, and a small git-CLI-backed verifier) now +lives as **committed, static** TypeScript under `e2e-tests/provider/runtime/` and +`e2e-tests/provider/support/git-verifier.ts`, scoped under the `e2e-tests/` directory rather than +generated per-run into a temp dir. `vitest.e2e.config.ts`'s `alias` map points `obsidian` at the +committed `obsidian-request-url.ts`; suites import `GitVerifier` directly by relative path — no +`@e2e-runtime/*` ambient module, no `E2E_RUNTIME_DIR`. + +**Open scanner-risk caveat:** the previous harness generation was removed specifically because a +prior committed-`.ts` version of this same code was flagged by the Obsidian scanner, and this +repo's own audit (`docs/obsidian-scanner-audit.md`) recorded that the scanner's flagging did not +appear to be scoped to what a submission actually bundles into `main.js`. Re-committing these +files under `e2e-tests/` on the premise that the scanner only inspects `manifest.json`'s declared +plugin surface is **unverified** against the real scanner as of this change — see "Known gaps" +below. If a rescan reproduces the earlier finding, the fallback is reverting to per-run generation +into an uncommitted directory (the previous design, preserved in git history), not a further +directory rename. ## Layout @@ -62,17 +65,24 @@ uses those APIs at all: - `scripts/run-e2e.sh` — the shared local/CI entry point: provision → seed → vitest → cleanup. It allocates a unique temporary workdir when the caller does not supply one, so concurrent local runs cannot overwrite each other's repository, runtime adapters, or credentials. -- `e2e/config/env.ts` — reads the env vars `provision` resolved and constructs the real, - already-configured `GitServiceInterface` per provider (`githubContext`/`gitlabContext`/ - `giteaContext`). -- `e2e/verifier-runtime-types.ts` — type-only `GitVerifier` contract the generated git-CLI - verifier implements. -- `e2e/shim/fake-vault.ts` — real in-memory Obsidian Vault/App stand-in (not a `vi.fn()` mock); - the only thing faked, since the point of this harness is exercising real `SyncManager` + - real provider code against a real Git server. -- `e2e/suites/{github,gitlab,gitea}.e2e.test.ts` — provider contract suites (create/read/ - update/delete/batch/rename, plus provider-specific regressions). -- `e2e/suites/sync-manager.e2e.test.ts` — one suite, parametrized by `E2E_PROVIDER`, covering +- `e2e-tests/provider/config/env.ts` — reads the env vars `provision` resolved and constructs + the real, already-configured `GitServiceInterface` per provider (`githubContext`/ + `gitlabContext`/`giteaContext`). +- `e2e-tests/provider/runtime/obsidian-request-url.ts` — the real `requestUrl` shim (and the + minimal Obsidian class stand-ins production code touches), aliased in for `obsidian` by + `vitest.e2e.config.ts`. +- `e2e-tests/provider/runtime/window-timers.ts` — the `window` = `globalThis` alias, loaded via + `vitest.e2e.config.ts`'s `setupFiles`. +- `e2e-tests/provider/support/git-verifier.ts` — the git-CLI-backed `GitVerifier` every suite + imports directly; reads the clone path from `E2E_WORKDIR` at call time rather than a baked-in + constant. +- `e2e-tests/provider/shim/fake-vault.ts` — real in-memory Obsidian Vault/App stand-in (not a + `vi.fn()` mock); the only thing faked, since the point of this harness is exercising real + `SyncManager` + real provider code against a real Git server. +- `e2e-tests/provider/suites/{github,gitlab,gitea}.e2e.test.ts` — provider contract suites + (create/read/update/delete/batch/rename, plus provider-specific regressions). +- `e2e-tests/provider/suites/sync-manager.e2e.test.ts` — one suite, parametrized by + `E2E_PROVIDER`, covering `SyncManager.pushFiles`/`pullFile`/`trackRename`/`clearMetadata` against a real provider. ## Isolation model @@ -238,8 +248,9 @@ The E2E jobs are separated by trust boundary. Gitea runs on a fresh GitHub-hoste `contents: read`; it is safe for fork PRs because it receives no repository secrets and cannot access the persistent runner fleet. The credentialed `github`/`gitlab` matrix remains self-hosted, and its job-level condition rejects fork PRs before runner allocation. Both depend on the -`changes` job's path gate (`src/services/**`, -`src/logic/sync-manager.ts`, `e2e/**`, `scripts/e2e-harness.sh`, `scripts/e2e-namespace.sh`, etc. — +`changes` job's path gate (`src/services/**`, `src/logic/sync-manager.ts`, `src/logic/sync/**`, +`src/logic/source-control/**`, `e2e-tests/**`, `vitest.e2e.config.ts`, `scripts/e2e-suites.txt`, +`scripts/e2e-harness.sh`, `scripts/e2e-namespace.sh`, etc. — computed by the `CI / Detect Changes` job, since GitHub Actions' own `on.*.paths` would gate the *entire* workflow file, including the always-must-run validation/release jobs). It always runs in full on `workflow_dispatch`, `schedule` (weekly, Monday 06:00 UTC, for API-drift detection), and @@ -328,6 +339,17 @@ GitHub/GitLab checks it cannot produce. - The official Obsidian community-plugin scanner rescan (as opposed to this repo's own grep-based self-audit, `docs/obsidian-scanner-audit.md`) hasn't been re-run against this harness from this checkout. +- **Committed-vs-generated risk, unresolved**: `e2e-tests/provider/runtime/` and + `e2e-tests/provider/support/git-verifier.ts` are committed `.ts` files using + `fetch`/`globalThis`/`node:child_process` — the same APIs a *prior* version of this harness + had flagged by the scanner while committed under `e2e/`. That prior removal's own audit + (`docs/obsidian-scanner-audit.md`) found the scanner's flagging was not evidently scoped to + `manifest.json`'s declared plugin surface. This PR bets that a directory outside `e2e/` (now + `e2e-tests/`) resolves that, on the premise the scanner only inspects what a submission + bundles — that premise has not been re-verified against the actual scanner. If a rescan + reproduces the earlier finding, revert to per-run generation into an uncommitted + `$E2E_WORKDIR`-scoped directory (git history has the prior implementation), not another + directory rename. - The Phase 2 isolation model (namespace scheme, per-source/provider concurrency groups, `e2e-pr-cleanup.yml`, `e2e-branch-cleanup.yml`, `e2e-janitor.yml`) is verified by local unit-level exercises of `scripts/e2e-namespace.sh`/`e2e-namespace-cleanup.sh`/`e2e-janitor.sh` diff --git a/e2e/config/env.ts b/e2e-tests/provider/config/env.ts similarity index 93% rename from e2e/config/env.ts rename to e2e-tests/provider/config/env.ts index b3f7f87..3566c62 100644 --- a/e2e/config/env.ts +++ b/e2e-tests/provider/config/env.ts @@ -8,10 +8,10 @@ * GitServiceInterface implementation against whatever that step already * resolved, via the env vars it exports (see docs/testing/real-provider-e2e.md). */ -import { GitHubService } from '../../src/services/github-service'; -import { GitLabService } from '../../src/services/gitlab-service'; -import { GiteaService } from '../../src/services/gitea-service'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; +import { GitHubService } from '../../../src/services/github-service'; +import { GitLabService } from '../../../src/services/gitlab-service'; +import { GiteaService } from '../../../src/services/gitea-service'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; export const SUPPORTED_PROVIDERS = ['gitea', 'gitlab', 'github'] as const; export type E2EProvider = typeof SUPPORTED_PROVIDERS[number]; diff --git a/e2e-tests/provider/runtime/obsidian-request-url.ts b/e2e-tests/provider/runtime/obsidian-request-url.ts new file mode 100644 index 0000000..df66ce9 --- /dev/null +++ b/e2e-tests/provider/runtime/obsidian-request-url.ts @@ -0,0 +1,48 @@ +// Real `requestUrl` shim for E2E: production services import this from +// `obsidian` (see vitest.e2e.config.ts's `alias`), and E2E suites need actual +// network calls to reach the provisioned provider — the `vi.fn()` mock +// tests/setup.ts installs for unit tests is deliberately not used here. +import type { RequestUrlParam, RequestUrlResponse } from 'obsidian'; + +export async function requestUrl(request: RequestUrlParam | string): Promise { + const params: RequestUrlParam = typeof request === 'string' ? { url: request } : request; + const shouldThrow = params.throw ?? true; + const headers: Record = { ...params.headers }; + if (params.contentType && !headers['Content-Type']) headers['Content-Type'] = params.contentType; + const res = await fetch(params.url, { method: params.method ?? 'GET', headers, body: params.body }); + const arrayBuffer = await res.arrayBuffer(); + const text = new TextDecoder().decode(arrayBuffer); + let json: unknown; + try { json = text ? JSON.parse(text) : undefined; } catch { json = undefined; } + const response: RequestUrlResponse = { status: res.status, headers: Object.fromEntries(res.headers.entries()), arrayBuffer, text, json }; + if (shouldThrow && res.status >= 400) { + const error = new Error(`Request failed, status ${res.status}`); + (error as Error & { status: number }).status = res.status; + throw error; + } + return response; +} + +export class Modal { + app: unknown; + constructor(app?: unknown) { this.app = app; } + open(): void {} + close(): void {} +} +export class PluginSettingTab { constructor(_app?: unknown, _plugin?: unknown) {} } +export class TextComponent {} +export class AbstractInputSuggest<_T> { constructor(_app: unknown, _inputEl: unknown) {} } +export class TFolder { path: string; constructor(path: string) { this.path = path; } } +export class Setting { constructor(_containerEl?: unknown) {} } +export class TFile { + path: string; + name: string; + constructor(path: string) { this.path = path; this.name = path.split('/').pop() ?? path; } +} +export class Notice { + constructor(_message?: string, _timeout?: number) {} + setMessage(): this { return this; } + hide(): void {} +} +export const Platform = { isDesktopApp: false, isMobile: false }; +export class FileSystemAdapter { getBasePath(): string { return '/e2e/fake-vault'; } } diff --git a/e2e-tests/provider/runtime/window-timers.ts b/e2e-tests/provider/runtime/window-timers.ts new file mode 100644 index 0000000..dbed09d --- /dev/null +++ b/e2e-tests/provider/runtime/window-timers.ts @@ -0,0 +1,5 @@ +// Minimal `window` alias so production code written for Obsidian's Electron +// renderer (e.g. window.setTimeout) runs as-is under Node. +if (typeof (globalThis as { window?: unknown }).window === 'undefined') { + (globalThis as unknown as { window: typeof globalThis }).window = globalThis; +} diff --git a/e2e/shim/fake-vault.ts b/e2e-tests/provider/shim/fake-vault.ts similarity index 95% rename from e2e/shim/fake-vault.ts rename to e2e-tests/provider/shim/fake-vault.ts index 372c4e2..a45cec1 100644 --- a/e2e/shim/fake-vault.ts +++ b/e2e-tests/provider/shim/fake-vault.ts @@ -2,7 +2,7 @@ import type { App } from 'obsidian'; /** * Real in-memory Obsidian Vault/App stand-in for SyncManager E2E (see - * e2e/suites/sync-manager.e2e.test.ts) — not a `vi.fn()` mock. The point of + * e2e-tests/provider/suites/sync-manager.e2e.test.ts) — not a `vi.fn()` mock. The point of * SyncManager E2E is to exercise real `SyncManager` + real provider service * code against a real Git server; the *only* thing worth faking is the * Obsidian filesystem boundary, so this implements exactly the `vault`/ @@ -11,8 +11,8 @@ import type { App } from 'obsidian'; * * `TFile` itself has to come from the caller rather than being imported here: * production code does `fileOrPath instanceof TFile`, so it must be the exact - * same class the vitest-runtime `obsidian` alias resolves to (generated by - * `scripts/e2e-harness.sh provision`, not committed — see + * same class the vitest `obsidian` alias resolves to + * (e2e-tests/provider/runtime/obsidian-request-url.ts — see * docs/testing/real-provider-e2e.md), not a second, unrelated class. */ export interface TFileLike { path: string; name: string } diff --git a/e2e/suites/gitea.e2e.test.ts b/e2e-tests/provider/suites/gitea.e2e.test.ts similarity index 94% rename from e2e/suites/gitea.e2e.test.ts rename to e2e-tests/provider/suites/gitea.e2e.test.ts index 252555f..846c297 100644 --- a/e2e/suites/gitea.e2e.test.ts +++ b/e2e-tests/provider/suites/gitea.e2e.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { GitVerifier } from '@e2e-runtime/git-verifier'; +import { GitVerifier } from '../support/git-verifier'; import { giteaContext } from '../config/env'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; // Real GiteaService against a real, freshly-provisioned Gitea instance (the // container itself was already brought up by `scripts/e2e-harness.sh @@ -13,7 +12,7 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; describe('GiteaService E2E', () => { let service: GitServiceInterface; let branch: string; - let verifier: GitVerifierType; + let verifier: GitVerifier; const runId = Math.random().toString(36).slice(2, 10); const path = (name: string) => `e2e-${runId}/${name}`; diff --git a/e2e/suites/github.e2e.test.ts b/e2e-tests/provider/suites/github.e2e.test.ts similarity index 96% rename from e2e/suites/github.e2e.test.ts rename to e2e-tests/provider/suites/github.e2e.test.ts index 228980e..558c43d 100644 --- a/e2e/suites/github.e2e.test.ts +++ b/e2e-tests/provider/suites/github.e2e.test.ts @@ -1,18 +1,17 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { GitVerifier } from '@e2e-runtime/git-verifier'; +import { GitVerifier } from '../support/git-verifier'; import { githubContext } from '../config/env'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; // Real GitHubService against a real GitHub sandbox repository, on the // isolated branch `scripts/e2e-harness.sh provision` already created (see // docs/testing/real-provider-e2e.md). Every remote assertion below goes -// through `verifier` (plain git CLI against an independent clone, generated -// by the harness) rather than asking `service` to read back its own writes. +// through `verifier` (plain git CLI against an independent clone the harness +// checked out) rather than asking `service` to read back its own writes. describe('GitHubService E2E', () => { let service: GitServiceInterface; let branch: string; - let verifier: GitVerifierType; + let verifier: GitVerifier; const runId = Math.random().toString(36).slice(2, 10); const path = (name: string) => `e2e-${runId}/${name}`; diff --git a/e2e/suites/gitlab.e2e.test.ts b/e2e-tests/provider/suites/gitlab.e2e.test.ts similarity index 97% rename from e2e/suites/gitlab.e2e.test.ts rename to e2e-tests/provider/suites/gitlab.e2e.test.ts index d0d7d76..5de0223 100644 --- a/e2e/suites/gitlab.e2e.test.ts +++ b/e2e-tests/provider/suites/gitlab.e2e.test.ts @@ -1,8 +1,7 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { GitVerifier } from '@e2e-runtime/git-verifier'; +import { GitVerifier } from '../support/git-verifier'; import { gitlabContext } from '../config/env'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; // Real GitLabService against a dedicated real GitLab sandbox project, on the // isolated branch `scripts/e2e-harness.sh provision` already created. Every @@ -11,7 +10,7 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; describe('GitLabService E2E', () => { let service: GitServiceInterface; let branch: string; - let verifier: GitVerifierType; + let verifier: GitVerifier; const runId = Math.random().toString(36).slice(2, 10); const path = (name: string) => `e2e-${runId}/${name}`; diff --git a/e2e/suites/source-control-flows.e2e.test.ts b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts similarity index 99% rename from e2e/suites/source-control-flows.e2e.test.ts rename to e2e-tests/provider/suites/source-control-flows.e2e.test.ts index b65875a..6dcc188 100644 --- a/e2e/suites/source-control-flows.e2e.test.ts +++ b/e2e-tests/provider/suites/source-control-flows.e2e.test.ts @@ -8,9 +8,9 @@ import { timeouts } from '../config/env'; // receives the mocked modules and installs their mockImplementation. Pull-side // SyncConflictModal stays the bare automock default (does nothing, matching // production: pullFile returns before the conflict modal resolves). -vi.mock('../../src/ui/SyncPlanModal'); -vi.mock('../../src/ui/SyncConflictModal'); -vi.mock('../../src/ui/BatchConflictResolutionModal'); +vi.mock('../../../src/ui/SyncPlanModal'); +vi.mock('../../../src/ui/SyncConflictModal'); +vi.mock('../../../src/ui/BatchConflictResolutionModal'); // Provider matrix: Core scenarios run on every provider; Extended scenarios // (rename chains, unicode, batch-scale, etc.) exercise SyncManager/model diff --git a/e2e/suites/sync-manager.e2e.test.ts b/e2e-tests/provider/suites/sync-manager.e2e.test.ts similarity index 93% rename from e2e/suites/sync-manager.e2e.test.ts rename to e2e-tests/provider/suites/sync-manager.e2e.test.ts index 307ba68..d0c5406 100644 --- a/e2e/suites/sync-manager.e2e.test.ts +++ b/e2e-tests/provider/suites/sync-manager.e2e.test.ts @@ -1,20 +1,19 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; -import { SyncManager, BatchPushConflict, ConflictResolution } from '../../src/logic/sync-manager'; -import { SyncPlanModal, SyncPlanDirection } from '../../src/ui/SyncPlanModal'; -import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; -import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; +import { SyncManager, BatchPushConflict, ConflictResolution } from '../../../src/logic/sync-manager'; +import { SyncPlanModal, SyncPlanDirection } from '../../../src/ui/SyncPlanModal'; +import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal'; +import { ObsidianSyncInteraction } from '../../../src/ui/ObsidianSyncInteraction'; import { describePushResult } from '../support/push-result-diagnostic'; // `import type` deliberately, not a value import: src/settings.ts also // exports settings-tab UI (GitLabSyncSettingTab -> FolderSuggest -> // AbstractInputSuggest etc.) which pulls in far more of `obsidian` than this -// suite's minimal generated shim provides. A type-only import is erased +// suite's minimal runtime shim provides. A type-only import is erased // entirely, so none of that module ever loads. -import type { GitLabFilesPushSettings } from '../../src/settings'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; import { TFile as ObsidianTFile } from 'obsidian'; -import { GitVerifier } from '@e2e-runtime/git-verifier'; +import { GitVerifier } from '../support/git-verifier'; import { FakeVault, fakeApp, type TFileLike, type TFileCtor } from '../shim/fake-vault'; import { currentProvider, timeouts, contextFor } from '../config/env'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; // Every push/pull SyncManager does shows a plan-review modal first, and any // push-side content conflict now goes through BatchConflictResolutionModal @@ -23,9 +22,9 @@ import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; // for unit tests. Pull-side conflicts still go through SyncConflictModal, // left as the bare automock default (does nothing, matching production: // pullFile returns before the conflict modal resolves). -vi.mock('../../src/ui/SyncPlanModal'); -vi.mock('../../src/ui/SyncConflictModal'); -vi.mock('../../src/ui/BatchConflictResolutionModal'); +vi.mock('../../../src/ui/SyncPlanModal'); +vi.mock('../../../src/ui/SyncConflictModal'); +vi.mock('../../../src/ui/BatchConflictResolutionModal'); function makeSettings(branch: string): GitLabFilesPushSettings { return { @@ -48,17 +47,17 @@ function makeSettings(branch: string): GitLabFilesPushSettings { /** * Real SyncManager + real production provider service (see - * e2e/config/env.ts), driven against whichever provider `E2E_PROVIDER` + * e2e-tests/provider/config/env.ts), driven against whichever provider `E2E_PROVIDER` * selects -- the same branch/verifier the contract suites use, so this suite * adds no provider-specific logic of its own. Only the Obsidian filesystem - * boundary is faked (e2e/shim/fake-vault.ts); everything else is the real + * boundary is faked (e2e-tests/provider/shim/fake-vault.ts); everything else is the real * code path. */ describe('SyncManager E2E', () => { const provider = currentProvider(); let service: ReturnType['service']; let branch: string; - let verifier: GitVerifierType; + let verifier: GitVerifier; let TFile: TFileCtor; let conflictResolver: (conflict: BatchPushConflict) => ConflictResolution; const runId = Math.random().toString(36).slice(2, 10); diff --git a/e2e/suites/two-client-sync.e2e.test.ts b/e2e-tests/provider/suites/two-client-sync.e2e.test.ts similarity index 97% rename from e2e/suites/two-client-sync.e2e.test.ts rename to e2e-tests/provider/suites/two-client-sync.e2e.test.ts index d7fb3fd..46a95a9 100644 --- a/e2e/suites/two-client-sync.e2e.test.ts +++ b/e2e-tests/provider/suites/two-client-sync.e2e.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, vi } from 'vitest'; -import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; +import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal'; import { createSyncManagerFixture, type SyncManagerFixture } from '../support/sync-manager-fixture'; import { TwoClientSyncScenario } from '../support/two-client-sync-scenario'; import { @@ -9,7 +9,7 @@ import { expectNoSilentDataLoss, type ConvergenceContext, } from '../support/convergence-assertions'; -import type { BatchPushConflict, ConflictResolution } from '../../src/logic/sync/types'; +import type { BatchPushConflict, ConflictResolution } from '../../../src/logic/sync/types'; import { timeouts } from '../config/env'; // Same modal auto-confirm pattern as the other e2e suites: plan-review and @@ -17,9 +17,9 @@ import { timeouts } from '../config/env'; // fixture.setConflictResolver. Pull-side SyncConflictModal stays the bare // automock (does nothing — matching production: pullFile returns before the // modal resolves). -vi.mock('../../src/ui/SyncPlanModal'); -vi.mock('../../src/ui/SyncConflictModal'); -vi.mock('../../src/ui/BatchConflictResolutionModal'); +vi.mock('../../../src/ui/SyncPlanModal'); +vi.mock('../../../src/ui/SyncConflictModal'); +vi.mock('../../../src/ui/BatchConflictResolutionModal'); /** * Multi-client Sync E2E — two fully independent clients (A/B: separate diff --git a/e2e/support/convergence-assertions.ts b/e2e-tests/provider/support/convergence-assertions.ts similarity index 97% rename from e2e/support/convergence-assertions.ts rename to e2e-tests/provider/support/convergence-assertions.ts index 72e293f..c16204b 100644 --- a/e2e/support/convergence-assertions.ts +++ b/e2e-tests/provider/support/convergence-assertions.ts @@ -1,5 +1,5 @@ import { expect } from 'vitest'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitVerifier } from './git-verifier'; import type { TwoClient } from './two-client-sync-scenario'; /** @@ -14,7 +14,7 @@ import type { TwoClient } from './two-client-sync-scenario'; export interface ConvergenceContext { clients: [TwoClient, TwoClient]; branch: string; - verifier: GitVerifierType; + verifier: GitVerifier; /** Only paths under this run's namespace. */ runPrefix: string; } @@ -141,7 +141,7 @@ export async function expectTwoClientConvergence(context: ConvergenceContext): P /** Convenience: builds the assertion context from a scenario + run prefix. */ export function convergenceContext( clients: [TwoClient, TwoClient], - verifier: GitVerifierType, + verifier: GitVerifier, branch: string, runPrefix: string, ): ConvergenceContext { diff --git a/e2e-tests/provider/support/git-verifier.ts b/e2e-tests/provider/support/git-verifier.ts new file mode 100644 index 0000000..024d9a6 --- /dev/null +++ b/e2e-tests/provider/support/git-verifier.ts @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process'; + +/** + * Independent verifier backed by plain git CLI against the isolated clone + * `scripts/e2e-harness.sh` already checked out at `$E2E_WORKDIR/repo` -- + * never the service under test reading back its own writes. + * + * A suite must never call `service.getFile()` to confirm `service.pushFile()` + * worked — that only proves the service agrees with itself, not that the + * remote actually changed. Every remote assertion in an E2E suite goes + * through one of these methods instead. + */ +export class GitVerifier { + constructor(private readonly repoDir: string = defaultRepoDir()) {} + + private git(args: string[]): string { + try { + return execFileSync('git', ['-C', this.repoDir, ...args], { + encoding: 'utf-8', + // Pipe stderr so an *expected* missing path (getFile's + // try/catch -> null) stays silent instead of spamming the log + // with "fatal: path does not exist". A genuine, unexpected git + // failure still surfaces: callers without their own try/catch + // re-throw below with the captured stderr attached. + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (error) { + const stderr = error && typeof error === 'object' && 'stderr' in error + ? String(error.stderr).trim() + : ''; + throw new Error( + `git ${args.join(' ')} failed` + (stderr ? `:\n${stderr}` : ''), + ); + } + } + + private fetch(ref: string): void { + this.git(['fetch', 'origin', ref]); + } + + async getFile(path: string, ref: string): Promise<{ content: string; sha: string } | null> { + this.fetch(ref); + try { + const sha = this.git(['rev-parse', `origin/${ref}:${path}`]).trim(); + const content = this.git(['show', `origin/${ref}:${path}`]); + return { content, sha }; + } catch { + return null; + } + } + + async listFiles(ref: string): Promise { + this.fetch(ref); + return this.git(['ls-tree', '-r', '--name-only', `origin/${ref}`]) + .split('\n') + .filter(Boolean); + } + + async fileMissing(path: string, ref: string): Promise { + return (await this.getFile(path, ref)) === null; + } + + async listCommitShas(ref: string, perPage = 30): Promise { + this.fetch(ref); + return this.git(['log', '--format=%H', '-n', String(perPage), `origin/${ref}`]) + .split('\n') + .filter(Boolean); + } + + /** Git tree mode at path (e.g. "120000" for a symlink). */ + async getBlobMode(path: string, ref: string): Promise { + this.fetch(ref); + const line = this.git(['ls-tree', `origin/${ref}`, '--', path]).trim(); + if (!line) return null; + return line.split(/\s+/)[0] ?? null; + } + + async getCommitMessage(sha: string): Promise { + return this.git(['log', '-1', '--format=%B', sha]).trim(); + } + + /** Last commit sha that touched path -- GitLab's optimistic-locking "revision". */ + async getRevision(path: string, ref: string): Promise { + this.fetch(ref); + const sha = this.git(['log', '-1', '--format=%H', `origin/${ref}`, '--', path]).trim(); + return sha || null; + } +} + +// `scripts/run-e2e.sh` always exports E2E_WORKDIR (from provision's +// e2e.env) into the vitest process before suites run; the clone this +// verifier reads lives at `$E2E_WORKDIR/repo` (see e2e-harness.sh's +// clone_dir()). +function defaultRepoDir(): string { + const workdir = process.env.E2E_WORKDIR; + if (!workdir) { + throw new Error( + 'E2E_WORKDIR is not set -- GitVerifier must run via scripts/run-e2e.sh (or the CI steps), not npx vitest directly.', + ); + } + return `${workdir}/repo`; +} diff --git a/e2e/support/push-result-diagnostic.ts b/e2e-tests/provider/support/push-result-diagnostic.ts similarity index 100% rename from e2e/support/push-result-diagnostic.ts rename to e2e-tests/provider/support/push-result-diagnostic.ts diff --git a/e2e/support/source-control-scenarios.ts b/e2e-tests/provider/support/source-control-scenarios.ts similarity index 90% rename from e2e/support/source-control-scenarios.ts rename to e2e-tests/provider/support/source-control-scenarios.ts index 30121d4..f467387 100644 --- a/e2e/support/source-control-scenarios.ts +++ b/e2e-tests/provider/support/source-control-scenarios.ts @@ -1,22 +1,22 @@ import { expect } from 'vitest'; import type { TFile } from 'obsidian'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; -import type { SyncManager } from '../../src/logic/sync-manager'; -import type { BatchPushConflict, ConflictResolution, PushResults } from '../../src/logic/sync/types'; -import type { GitLabFilesPushSettings } from '../../src/settings'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; +import type { SyncManager } from '../../../src/logic/sync-manager'; +import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; import type { FakeVault, TFileLike } from '../shim/fake-vault'; import type { SyncManagerFixture } from './sync-manager-fixture'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; -import { ChangeRepository } from '../../src/logic/source-control/ChangeRepository'; -import { OperationState } from '../../src/logic/source-control/OperationState'; -import { SyncSelectionStore } from '../../src/logic/source-control/SyncSelectionStore'; -import { SourceControlActionService } from '../../src/logic/source-control/SourceControlActionService'; -import { BoundarySyncWorkspace } from '../../src/logic/sync/SyncWorkspace'; -import { toChangeId, type SyncChange } from '../../src/logic/source-control/types'; -import type { SyncStatusRefreshResult } from '../../src/logic/sync/SyncStatusRefreshService'; -import type { RemoteDeleteResult } from '../../src/logic/sync/RemoteDeleteExecutor'; -import type { FileDiff } from '../../src/logic/sync/types'; -import type { GitTreeEntry } from '../../src/services/git-service-interface'; +import type { GitVerifier } from './git-verifier'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { SyncSelectionStore } from '../../../src/logic/source-control/SyncSelectionStore'; +import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService'; +import { BoundarySyncWorkspace } from '../../../src/logic/sync/SyncWorkspace'; +import { toChangeId, type SyncChange } from '../../../src/logic/source-control/types'; +import type { SyncStatusRefreshResult } from '../../../src/logic/sync/SyncStatusRefreshService'; +import type { RemoteDeleteResult } from '../../../src/logic/sync/RemoteDeleteExecutor'; +import type { FileDiff } from '../../../src/logic/sync/types'; +import type { GitTreeEntry } from '../../../src/services/git-service-interface'; /** * High-level scenario wrapper around a {@link SyncManagerFixture}: owns one @@ -35,7 +35,7 @@ export class SourceControlScenario { readonly settings: GitLabFilesPushSettings; readonly manager: SyncManager; private readonly service: GitServiceInterface; - private readonly verifier: GitVerifierType; + private readonly verifier: GitVerifier; private readonly branch: string; /** * Memoizes remote reads (each of which is a real `git fetch` round trip) diff --git a/e2e/support/sync-manager-fixture.ts b/e2e-tests/provider/support/sync-manager-fixture.ts similarity index 84% rename from e2e/support/sync-manager-fixture.ts rename to e2e-tests/provider/support/sync-manager-fixture.ts index 8bf4698..ed5e738 100644 --- a/e2e/support/sync-manager-fixture.ts +++ b/e2e-tests/provider/support/sync-manager-fixture.ts @@ -1,20 +1,19 @@ import { vi } from 'vitest'; -import { SyncManager } from '../../src/logic/sync-manager'; -import type { BatchPushConflict, ConflictResolution, PushResults } from '../../src/logic/sync/types'; -import { SyncPlanModal, type SyncPlanDirection } from '../../src/ui/SyncPlanModal'; -import { BatchConflictResolutionModal } from '../../src/ui/BatchConflictResolutionModal'; -import { ObsidianSyncInteraction } from '../../src/ui/ObsidianSyncInteraction'; +import { SyncManager } from '../../../src/logic/sync-manager'; +import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types'; +import { SyncPlanModal, type SyncPlanDirection } from '../../../src/ui/SyncPlanModal'; +import { BatchConflictResolutionModal } from '../../../src/ui/BatchConflictResolutionModal'; +import { ObsidianSyncInteraction } from '../../../src/ui/ObsidianSyncInteraction'; // `import type` deliberately: settings.ts re-exports the settings-tab UI // (GitLabSyncSettingTab -> FolderSuggest -> AbstractInputSuggest) which pulls -// in far more of `obsidian` than this suite's generated shim provides. A +// in far more of `obsidian` than this suite's runtime shim provides. A // type-only import is erased entirely, so none of that module ever loads. -import type { GitLabFilesPushSettings } from '../../src/settings'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; import { TFile as ObsidianTFile } from 'obsidian'; -import { GitVerifier } from '@e2e-runtime/git-verifier'; +import { GitVerifier } from './git-verifier'; import { FakeVault, fakeApp, type TFileCtor } from '../shim/fake-vault'; import { currentProvider, contextFor } from '../config/env'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; const TFile: TFileCtor = ObsidianTFile; @@ -22,12 +21,13 @@ const TFile: TFileCtor = ObsidianTFile; * Reusable real-provider E2E fixture for SyncManager workflows. Owns the * once-per-suite wiring the old `e2e/suites/sync-manager.e2e.test.ts` kept in * its `beforeAll`: resolving the real production provider service + isolated - * branch, loading the generated git-CLI verifier + TFile shim, and installing + * branch, loading the git-CLI verifier + TFile shim, and installing * plan-review/conflict modals that auto-confirm (so a push can proceed without * a human clicking through). Per-test conflict outcomes are steered through * {@link setConflictResolver}. * - * Only the Obsidian filesystem boundary is faked (e2e/shim/fake-vault.ts); + * Only the Obsidian filesystem boundary is faked + * (e2e-tests/provider/shim/fake-vault.ts); * everything else — SyncManager, PushCoordinator, the provider service — is * the real production code path against a real Git server. */ @@ -36,8 +36,8 @@ export interface SyncManagerFixture { readonly service: GitServiceInterface; /** Isolated branch `scripts/e2e-harness.sh provision` created for this run. */ readonly branch: string; - /** Independent git-CLI verifier (generated at runtime, never committed). */ - readonly verifier: GitVerifierType; + /** Independent git-CLI verifier (e2e-tests/provider/support/git-verifier.ts). */ + readonly verifier: GitVerifier; /** The exact TFile class the vitest-runtime `obsidian` alias resolves to. */ readonly TFile: TFileCtor; /** Per-suite run id, so every test's remote paths are namespaced apart. */ @@ -62,7 +62,7 @@ export async function createSyncManagerFixture(): Promise { const service = ctx.service; const branch = ctx.branch; - const verifier: GitVerifierType = new GitVerifier(); + const verifier: GitVerifier = new GitVerifier(); let conflictResolver: (conflict: BatchPushConflict) => ConflictResolution = () => 'skip'; diff --git a/e2e/support/two-client-sync-scenario.ts b/e2e-tests/provider/support/two-client-sync-scenario.ts similarity index 90% rename from e2e/support/two-client-sync-scenario.ts rename to e2e-tests/provider/support/two-client-sync-scenario.ts index 232c772..b1824b8 100644 --- a/e2e/support/two-client-sync-scenario.ts +++ b/e2e-tests/provider/support/two-client-sync-scenario.ts @@ -1,21 +1,21 @@ import { expect } from 'vitest'; import type { TFile } from 'obsidian'; -import type { GitServiceInterface } from '../../src/services/git-service-interface'; -import type { GitLabFilesPushSettings } from '../../src/settings'; -import type { BatchPushConflict, ConflictResolution, PushResults } from '../../src/logic/sync/types'; -import type { SyncManager } from '../../src/logic/sync-manager'; -import type { FileStatus } from '../../src/logic/sync-status-service'; -import type { GitVerifier as GitVerifierType } from '../verifier-runtime-types'; +import type { GitServiceInterface } from '../../../src/services/git-service-interface'; +import type { GitLabFilesPushSettings } from '../../../src/settings'; +import type { BatchPushConflict, ConflictResolution, PushResults } from '../../../src/logic/sync/types'; +import type { SyncManager } from '../../../src/logic/sync-manager'; +import type { FileStatus } from '../../../src/logic/sync-status-service'; +import type { GitVerifier } from './git-verifier'; import type { FakeVault, TFileLike, TFileCtor } from '../shim/fake-vault'; import { fakeApp } from '../shim/fake-vault'; -import { SyncStatusRefreshService } from '../../src/logic/sync/SyncStatusRefreshService'; -import { SyncStatusService } from '../../src/logic/sync-status-service'; -import { GitignoreManager } from '../../src/logic/gitignore-manager'; -import { ensureSyncWorkspaceRuntime } from '../../src/logic/sync/SyncWorkspace'; -import { ChangeRepository } from '../../src/logic/source-control/ChangeRepository'; -import { OperationState } from '../../src/logic/source-control/OperationState'; -import { SourceControlActionService } from '../../src/logic/source-control/SourceControlActionService'; -import { toSyncChanges } from '../../src/logic/source-control/FileStatusAdapter'; +import { SyncStatusRefreshService } from '../../../src/logic/sync/SyncStatusRefreshService'; +import { SyncStatusService } from '../../../src/logic/sync-status-service'; +import { GitignoreManager } from '../../../src/logic/gitignore-manager'; +import { ensureSyncWorkspaceRuntime } from '../../../src/logic/sync/SyncWorkspace'; +import { ChangeRepository } from '../../../src/logic/source-control/ChangeRepository'; +import { OperationState } from '../../../src/logic/source-control/OperationState'; +import { SourceControlActionService } from '../../../src/logic/source-control/SourceControlActionService'; +import { toSyncChanges } from '../../../src/logic/source-control/FileStatusAdapter'; /** * The provider-level fixtures the two-client scenario shares across clients. @@ -25,7 +25,7 @@ import { toSyncChanges } from '../../src/logic/source-control/FileStatusAdapter' export interface TwoClientFixture { readonly service: GitServiceInterface; readonly branch: string; - readonly verifier: GitVerifierType; + readonly verifier: GitVerifier; readonly TFile: TFileCtor; /** Namespaced run id, so each test's remote paths stay apart. */ readonly runId: string; @@ -274,7 +274,7 @@ export class TwoClientSyncScenario { return tip!; } - private get verifier(): GitVerifierType { + private get verifier(): GitVerifier { return this.fixture.verifier; } } \ No newline at end of file diff --git a/e2e/runtime-modules.d.ts b/e2e/runtime-modules.d.ts deleted file mode 100644 index dd3a3cf..0000000 --- a/e2e/runtime-modules.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Compile-time contract for `@e2e-runtime/git-verifier`, which only resolves - * at runtime via the `vitest.e2e.config.ts` alias to the generated - * `${E2E_RUNTIME_DIR}/verifier/git-verifier.ts` (never committed — see - * docs/testing/real-provider-e2e.md). `verifier-runtime-types.ts` is the - * single source of truth for the shape; this just points the module - * specifier at it so suites can `import` it statically. - */ -declare module '@e2e-runtime/git-verifier' { - export const GitVerifier: new () => import('./verifier-runtime-types').GitVerifier; -} diff --git a/e2e/verifier-runtime-types.ts b/e2e/verifier-runtime-types.ts deleted file mode 100644 index 3d7bffb..0000000 --- a/e2e/verifier-runtime-types.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Type-only contract for the git-CLI-backed verifier `scripts/e2e-harness.sh - * provision` generates at `${E2E_RUNTIME_DIR}/verifier/git-verifier.ts` - * (never committed — see docs/testing/real-provider-e2e.md). Suites import - * the concrete implementation statically from `@e2e-runtime/git-verifier` - * (see `e2e/runtime-modules.d.ts`), which only resolves at E2E runtime via - * the `vitest.e2e.config.ts` alias — `npm run build`'s typecheck never needs - * the generated file to exist on disk. - * - * A suite must never call `service.getFile()` to confirm `service.pushFile()` - * worked — that only proves the service agrees with itself, not that the - * remote actually changed. Every remote assertion in an E2E suite goes - * through one of these methods instead. - */ -export interface GitVerifier { - /** Fetches raw file content + blob sha directly via `git show`/`git rev-parse`. */ - getFile(path: string, ref: string): Promise<{ content: string; sha: string } | null>; - - /** Lists all file paths present at `ref`, for verifying batch pushes/renames. */ - listFiles(ref: string): Promise; - - /** True if `path` does not exist at `ref` (used to verify deletes/renames-away). */ - fileMissing(path: string, ref: string): Promise; - - /** Commit shas on `ref`, newest first. */ - listCommitShas(ref: string, perPage?: number): Promise; - - /** Git tree entry mode at `path` (e.g. "120000" for a symlink). */ - getBlobMode(path: string, ref: string): Promise; - - /** Commit message at a given sha. */ - getCommitMessage(sha: string): Promise; - - /** Last commit sha that touched `path` on `ref` — GitLab's optimistic-locking "revision". */ - getRevision(path: string, ref: string): Promise; -} diff --git a/eslint.config.mts b/eslint.config.mts index fb5ac19..f90dfb0 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -126,17 +126,31 @@ export default tseslint.config( }, }, { - // E2E harness glue runs under Node (vitest, `environment: 'node'`), not - // Obsidian's Electron renderer — needs `process`, same as scripts/. Unlike - // scripts/, it deliberately keeps fetch/globalThis/node:* built-ins out - // (see docs/testing/real-provider-e2e.md), so it does NOT get the same - // import/no-nodejs-modules / no-restricted-globals exemptions. - files: ["e2e/**/*.ts", "vitest.e2e.config.ts"], + // e2e-tests/** is Node test tooling (vitest, `environment: 'node'`) that + // drives real GitHub/GitLab/Gitea sandboxes via the production provider + // code path — not shipping Obsidian plugin runtime, so it gets the same + // Node-tooling exemptions as scripts/ below. The real `requestUrl` shim + // and the git-CLI verifier genuinely need fetch/node:child_process. + // NOTE: an earlier committed-`.ts` version of this harness (see + // docs/obsidian-scanner-audit.md) was flagged by the Obsidian + // community-plugin scanner for these same APIs; that audit's own + // finding was that the scanner's grep is not scoped to what ships in + // main.js. Committing them again here under a new directory name is + // unverified against the actual scanner until it's re-run — see the + // "Known gaps" note this PR adds to docs/testing/real-provider-e2e.md. + files: ["e2e-tests/**/*.ts", "vitest.e2e.config.ts"], languageOptions: { globals: { ...globals.node, }, }, + rules: { + "import/no-nodejs-modules": "off", + "no-restricted-globals": "off", + "obsidianmd/rule-custom-message": "off", + "obsidianmd/no-nodejs-modules": "off", + "obsidianmd/no-global-this": "off", + }, }, globalIgnores([ "node_modules", diff --git a/progress.md b/progress.md index 31e41ec..3ce4cae 100644 --- a/progress.md +++ b/progress.md @@ -4,9 +4,13 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Current State -**Last Updated:** 2026-08-30 -**Active Feature:** PR #129 (`claude/source-control-foundation`) — Multi-client Sync E2E Hardening Phase 1–3 complete in working tree (uncommitted, on top of prior refactor rounds). -**Branch / PR:** `claude/source-control-foundation` / PR #129. +**Last Updated:** 2026-08-31 +**Active Feature:** Issue #142 — move real-provider E2E from `e2e/` to `e2e-tests/provider/` and replace `E2E_RUNTIME_DIR` per-run generation with committed static runtime files. Working tree changes complete, uncommitted. +**Branch / PR:** `refactor/e2e-tests-scanner-boundary`, branched off `claude/source-control-foundation` (PR #129, still open). Intended to retarget/rebase onto `main` once #129 merges — see #142 for the full plan. + +**Open risk, not yet resolved**: this PR's core premise — that a directory rename from `e2e/` to `e2e-tests/` makes committing `fetch`/`node:child_process` `.ts` files scanner-safe — is unverified against the actual Obsidian community-plugin scanner, and contradicts this repo's own prior audit (`docs/obsidian-scanner-audit.md`), which found the scanner flagged those exact APIs while committed under `e2e/` regardless of directory. Proceeded on explicit user instruction; flagged in `docs/testing/real-provider-e2e.md`'s "Known gaps" and `docs/obsidian-scanner-audit.md`'s Phase 2 section. **A real scanner rescan is required before trusting this.** + +Below that: the previous "Outstanding Items"/"Verification Evidence" entries track separate, still-open work on PR #129 / `claude/source-control-foundation` — not superseded by this entry. ## Outstanding Items @@ -17,6 +21,20 @@ Completed work is archived in [archive/](./archive/), one file per calendar mont ## Verification Evidence +This session (#142 — e2e/ → e2e-tests/provider/ scanner-boundary move, static runtime files): + +- Moved `e2e/{config,shim,suites,support}` → `e2e-tests/provider/{config,shim,suites,support}` (git mv, history preserved); deleted `e2e/runtime-modules.d.ts` and `e2e/verifier-runtime-types.ts`. +- Replaced `scripts/e2e-harness.sh`'s `generate_runtime()` (which wrote `obsidian-request-url.ts`/`window-timers.ts`/`git-verifier.ts` per-run into `$E2E_RUNTIME_DIR`) with committed static files at `e2e-tests/provider/runtime/{obsidian-request-url,window-timers}.ts` and `e2e-tests/provider/support/git-verifier.ts`. `GitVerifier` now reads its clone path from `process.env.E2E_WORKDIR` at call time instead of a shell-baked constructor default — verified directly against a throwaway local git repo (`listFiles`/`getFile`/`listCommitShas` all correct). +- Side effect: removing `generate_runtime()` also removed the file's only `${var@Q}` bash-4-ism, which previously blocked `scripts/e2e-harness.sh provision` under macOS system bash 3.2 (see "Outstanding Items" #1 below — that specific blocker no longer applies, though a live run still needs Docker, which this sandbox doesn't have running). +- Updated `scripts/run-e2e.sh`, `scripts/e2e-suites.txt`, `vitest.e2e.config.ts`, `tsconfig.json`, `eslint.config.mts`, `.github/workflows/ci.yml` (`e2e-relevant` filter: `e2e/**` → `e2e-tests/**`, added `scripts/e2e-suites.txt`/`vitest.e2e.config.ts`, added `src/logic/sync/**`/`src/logic/source-control/**` — these were exercised by the E2E suites but not previously watched by the path filter) for the new layout. +- Extended `tests/ci-workflow.test.ts` with contract assertions for the new paths and for the absence of `E2E_RUNTIME_DIR`/`generate_runtime`/`@e2e-runtime`. +- Manually replayed `scripts/run-e2e.sh`'s forward/reverse suite-manifest checks against the new paths (bash snippet, no Docker needed) — both pass. +- `npx eslint .` — 0 errors, 1 pre-existing-shape warning (unused `_T` generic in the committed `AbstractInputSuggest<_T>` stand-in, matches obsidian's real generic shape). +- `npm run build` (tsc + Obsidian 1.11.0 compat typecheck + esbuild) — passed. +- `npx vitest run` — 68 files / 857 tests passed. +- **Not verified in this environment**: an actual local Gitea E2E run (`npm run test:e2e -- --provider gitea`) — Docker is installed but its daemon isn't reachable/running in this sandbox. The suite-manifest and `GitVerifier` logic were validated by other means above, but the full provision→seed→vitest→cleanup path was not exercised end-to-end here. +- Filed #143 (`test: reduce real-provider API pressure`) as a separate follow-up for CI retry/tiering — out of scope for this PR, not touched here. + This session (Source Control error-handling fixes, code-review follow-up + small cleanups): - `DiffStatProvider.clear()` now also clears the per-row `generations` map (previously only `cache`/`queued`/`active` were cleared, so `generations` grew unbounded across refreshes); added a white-box regression test. diff --git a/scripts/e2e-harness.sh b/scripts/e2e-harness.sh index 1f6cad3..4043494 100755 --- a/scripts/e2e-harness.sh +++ b/scripts/e2e-harness.sh @@ -6,8 +6,7 @@ # # Subcommands: # provision create/resolve the isolated test branch (or, for gitea, -# the whole disposable container+repo) and generate the -# Node-only vitest runtime adapters under $E2E_RUNTIME_DIR +# the whole disposable container+repo) # seed write deterministic baseline fixtures to the branch # verify independent post-run sanity check (branch exists, has # the expected number of commits) — the fine-grained, @@ -54,8 +53,7 @@ fi # local dev falls back to a provider-namespaced (not random) tmp dir so # sequential `npm run test:e2e` steps in the same shell session share it too. workdir="${E2E_WORKDIR:-${TMPDIR:-/tmp}/gfs-e2e-${provider}}" -runtime_dir="${E2E_RUNTIME_DIR:-$workdir/runtime}" -mkdir -p "$workdir" "$runtime_dir" +mkdir -p "$workdir" keep_branch=0 case "${E2E_KEEP_BRANCH:-}" in @@ -182,7 +180,6 @@ cmd_provision() { git -C "$dir" push origin "${base_sha}:refs/heads/${E2E_TEST_BRANCH}" fi - generate_runtime write_env_file } @@ -237,159 +234,6 @@ cmd_cleanup() { git -C "$dir" push origin ":refs/heads/${E2E_TEST_BRANCH}" || true } -# --- generated vitest runtime (never committed) ----------------------------- - -# Everything under here is Node-only glue (fetch/globalThis/node:child_process) -# equivalent to what used to live in e2e/shim + e2e/verifier as committed -# .ts files -- generated fresh per run instead, so the checked-in suites stay -# free of the APIs the Obsidian scanner flags. See section 6/7 of the task -# and docs/testing/real-provider-e2e.md. -generate_runtime() { - mkdir -p "$runtime_dir/verifier" - - cat >"$runtime_dir/obsidian-request-url.ts" <<'EOF' -import type { RequestUrlParam, RequestUrlResponse } from 'obsidian'; - -export async function requestUrl(request: RequestUrlParam | string): Promise { - const params: RequestUrlParam = typeof request === 'string' ? { url: request } : request; - const shouldThrow = params.throw ?? true; - const headers: Record = { ...params.headers }; - if (params.contentType && !headers['Content-Type']) headers['Content-Type'] = params.contentType; - const res = await fetch(params.url, { method: params.method ?? 'GET', headers, body: params.body }); - const arrayBuffer = await res.arrayBuffer(); - const text = new TextDecoder().decode(arrayBuffer); - let json: unknown; - try { json = text ? JSON.parse(text) : undefined; } catch { json = undefined; } - const response: RequestUrlResponse = { status: res.status, headers: Object.fromEntries(res.headers.entries()), arrayBuffer, text, json }; - if (shouldThrow && res.status >= 400) { - const error = new Error(`Request failed, status ${res.status}`); - (error as Error & { status: number }).status = res.status; - throw error; - } - return response; -} - -export class Modal { - app: unknown; - constructor(app?: unknown) { this.app = app; } - open(): void {} - close(): void {} -} -export class PluginSettingTab { constructor(_app?: unknown, _plugin?: unknown) {} } -export class TextComponent {} -export class AbstractInputSuggest<_T> { constructor(_app: unknown, _inputEl: unknown) {} } -export class TFolder { path: string; constructor(path: string) { this.path = path; } } -export class Setting { constructor(_containerEl?: unknown) {} } -export class TFile { - path: string; - name: string; - constructor(path: string) { this.path = path; this.name = path.split('/').pop() ?? path; } -} -export class Notice { - constructor(_message?: string, _timeout?: number) {} - setMessage(): this { return this; } - hide(): void {} -} -export const Platform = { isDesktopApp: false, isMobile: false }; -export class FileSystemAdapter { getBasePath(): string { return '/e2e/fake-vault'; } } -EOF - - cat >"$runtime_dir/window-timers.ts" <<'EOF' -if (typeof (globalThis as { window?: unknown }).window === 'undefined') { - (globalThis as unknown as { window: typeof globalThis }).window = globalThis; -} -EOF - - local repo_dir; repo_dir=$(clone_dir) - cat >"$runtime_dir/verifier/git-verifier.ts" < null) stays silent instead of spamming the log - // with "fatal: path does not exist". A genuine, unexpected git - // failure still surfaces: callers without their own try/catch - // re-throw below with the captured stderr attached. - stdio: ['pipe', 'pipe', 'pipe'], - }); - } catch (error) { - const stderr = error && typeof error === 'object' && 'stderr' in error - ? String((error as { stderr: unknown }).stderr).trim() - : ''; - throw new Error( - \`git \${args.join(' ')} failed\` + (stderr ? \`:\\n\${stderr}\` : ''), - ); - } - } - - private fetch(ref: string): void { - this.git(['fetch', 'origin', ref]); - } - - async getFile(path: string, ref: string): Promise<{ content: string; sha: string } | null> { - this.fetch(ref); - try { - const sha = this.git(['rev-parse', \`origin/\${ref}:\${path}\`]).trim(); - const content = this.git(['show', \`origin/\${ref}:\${path}\`]); - return { content, sha }; - } catch { - return null; - } - } - - async listFiles(ref: string): Promise { - this.fetch(ref); - return this.git(['ls-tree', '-r', '--name-only', \`origin/\${ref}\`]) - .split('\\n') - .filter(Boolean); - } - - async fileMissing(path: string, ref: string): Promise { - return (await this.getFile(path, ref)) === null; - } - - async listCommitShas(ref: string, perPage = 30): Promise { - this.fetch(ref); - return this.git(['log', '--format=%H', '-n', String(perPage), \`origin/\${ref}\`]) - .split('\\n') - .filter(Boolean); - } - - /** Git tree mode at path (e.g. "120000" for a symlink). */ - async getBlobMode(path: string, ref: string): Promise { - this.fetch(ref); - const line = this.git(['ls-tree', \`origin/\${ref}\`, '--', path]).trim(); - if (!line) return null; - return line.split(/\\s+/)[0] ?? null; - } - - async getCommitMessage(sha: string): Promise { - return this.git(['log', '-1', '--format=%B', sha]).trim(); - } - - /** Last commit sha that touched path -- GitLab's optimistic-locking "revision". */ - async getRevision(path: string, ref: string): Promise { - this.fetch(ref); - const sha = this.git(['log', '-1', '--format=%H', \`origin/\${ref}\`, '--', path]).trim(); - return sha || null; - } -} -EOF - log "Generated vitest runtime adapters under $runtime_dir" -} - # --- gitea container lifecycle (shell/docker, never node:child_process) ----- provision_gitea_container() { @@ -492,12 +336,11 @@ write_env_file() { echo "E2E_BASE_BRANCH=$E2E_BASE_BRANCH" echo "E2E_TEST_BRANCH=$E2E_TEST_BRANCH" echo "E2E_WORKDIR=$workdir" - echo "E2E_RUNTIME_DIR=$runtime_dir" # Not a credential itself -- the token lives only in the mode-700 # askpass file on disk at this path (still present for later steps # in the same job, since it's written under $RUNNER_TEMP). Every git - # call the generated verifier makes (used by the vitest step, which - # never runs this script) needs these two set to authenticate. + # call the committed GitVerifier makes (used by the vitest step, + # which never runs this script) needs these two set to authenticate. echo "GIT_ASKPASS=$GIT_ASKPASS" echo "GIT_TERMINAL_PROMPT=0" } >"$env_file" diff --git a/scripts/e2e-suites.txt b/scripts/e2e-suites.txt index 775d876..7a42629 100644 --- a/scripts/e2e-suites.txt +++ b/scripts/e2e-suites.txt @@ -2,10 +2,10 @@ # per provider. scripts/run-e2e.sh reads this, expands ${provider}, and runs # them; CI calls run-e2e.sh so this list is never duplicated in the workflow. # scripts/run-e2e.sh's own forward/reverse checks enforce that every -# e2e/suites/*.e2e.test.ts is registered here: the ${provider} line covers the +# e2e-tests/provider/suites/*.e2e.test.ts is registered here: the ${provider} line covers the # provider-specific suites (github/gitlab/gitea); every other shared suite # must be listed explicitly, or the run fails. -e2e/suites/${provider}.e2e.test.ts -e2e/suites/sync-manager.e2e.test.ts -e2e/suites/source-control-flows.e2e.test.ts -e2e/suites/two-client-sync.e2e.test.ts \ No newline at end of file +e2e-tests/provider/suites/${provider}.e2e.test.ts +e2e-tests/provider/suites/sync-manager.e2e.test.ts +e2e-tests/provider/suites/source-control-flows.e2e.test.ts +e2e-tests/provider/suites/two-client-sync.e2e.test.ts \ No newline at end of file diff --git a/scripts/run-e2e.sh b/scripts/run-e2e.sh index d946c58..8d3ef42 100755 --- a/scripts/run-e2e.sh +++ b/scripts/run-e2e.sh @@ -89,7 +89,7 @@ if [ "$manifest_has_dynamic" -ne 1 ]; then exit 1 fi -SUITES=("e2e/suites/${provider}.e2e.test.ts" "${SHARED_SUITES[@]}") +SUITES=("e2e-tests/provider/suites/${provider}.e2e.test.ts" "${SHARED_SUITES[@]}") # Forward check: every manifest entry (after ${provider} expansion) must # exist on disk -- catches a typo'd or deleted suite path in the manifest. @@ -100,7 +100,7 @@ for suite in "${SUITES[@]}"; do fi done -# Reverse check: every e2e/suites/*.e2e.test.ts file on disk must be either a +# Reverse check: every e2e-tests/provider/suites/*.e2e.test.ts file on disk must be either a # known provider suite (github/gitlab/gitea -- covered by the ${provider} # line regardless of which provider this run targets) or a shared suite # explicitly registered in the manifest. Catches a new suite file added @@ -114,7 +114,7 @@ is_shared_suite() { return 1 } unregistered=() -for file in e2e/suites/*.e2e.test.ts; do +for file in e2e-tests/provider/suites/*.e2e.test.ts; do [ -e "$file" ] || continue base="$(basename "$file" .e2e.test.ts)" is_known_provider=0 @@ -133,7 +133,7 @@ fi echo "[run-e2e] tier=$E2E_TIER provider=$E2E_PROVIDER" >&2 echo "[run-e2e] running suites: ${SUITES[*]}" >&2 -# vitest.e2e.config.ts's `include` matches every e2e/suites/*.e2e.test.ts, so +# vitest.e2e.config.ts's `include` matches every e2e-tests/provider/suites/*.e2e.test.ts, so # the other two providers' suites would also try to run (and fail on missing # credentials) if not explicitly limited to this list. source-control-flows # gates its Extended scenarios to GitHub only (and 1000-file stress to diff --git a/tests/ci-workflow.test.ts b/tests/ci-workflow.test.ts index dc28b9b..ce85aea 100644 --- a/tests/ci-workflow.test.ts +++ b/tests/ci-workflow.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'; const workflow = readFileSync('.github/workflows/ci.yml', 'utf8'); const harness = readFileSync('scripts/e2e-harness.sh', 'utf8'); const runner = readFileSync('scripts/run-e2e.sh', 'utf8'); +const vitestE2eConfig = readFileSync('vitest.e2e.config.ts', 'utf8'); +const eslintConfig = readFileSync('eslint.config.mts', 'utf8'); describe('CI workflow contracts', () => { it('retries transient provider failures three times', () => { @@ -89,3 +91,44 @@ describe('local Gitea harness contracts', () => { expect(runner).toContain('created_workdir=1'); }); }); + +describe('E2E scanner-boundary contracts (e2e-tests/provider, no runtime generation)', () => { + it('triggers E2E on the e2e-tests/** boundary, not the old e2e/** path', () => { + expect(workflow).toContain("- 'e2e-tests/**'"); + expect(workflow).not.toContain("- 'e2e/**'"); + }); + + it('triggers E2E when the suite manifest or the E2E vitest config changes', () => { + expect(workflow).toContain("- 'scripts/e2e-suites.txt'"); + expect(workflow).toContain("- 'vitest.e2e.config.ts'"); + }); + + it('triggers E2E on the real sync/ and source-control/ logic directories, not just the sync-manager.ts compat shim', () => { + expect(workflow).toContain("- 'src/logic/sync/**'"); + expect(workflow).toContain("- 'src/logic/source-control/**'"); + }); + + it('runs suites from e2e-tests/provider/suites, not the old e2e/suites path', () => { + expect(runner).toContain('e2e-tests/provider/suites'); + expect(runner).not.toContain('e2e/suites'); + }); + + it('does not generate scanner-workaround runtime adapters at provision time', () => { + expect(harness).not.toContain('generate_runtime'); + expect(harness).not.toContain('E2E_RUNTIME_DIR'); + expect(harness).not.toMatch(/runtime_dir/); + }); + + it('points vitest.e2e.config.ts at committed static runtime files, not an E2E_RUNTIME_DIR alias', () => { + expect(vitestE2eConfig).not.toContain('E2E_RUNTIME_DIR'); + expect(vitestE2eConfig).not.toContain('@e2e-runtime'); + expect(vitestE2eConfig).toContain('./e2e-tests/provider/runtime/obsidian-request-url.ts'); + expect(vitestE2eConfig).toContain('./e2e-tests/provider/runtime/window-timers.ts'); + expect(vitestE2eConfig).toContain("include: ['e2e-tests/provider/suites/**/*.e2e.test.ts']"); + }); + + it('scopes e2e-tests/** Node-tooling lint exemptions to that directory, not the whole repo', () => { + expect(eslintConfig).toContain('"e2e-tests/**/*.ts"'); + expect(eslintConfig).not.toContain('"e2e/**/*.ts"'); + }); +}); diff --git a/tests/e2e/push-result-diagnostic.test.ts b/tests/e2e/push-result-diagnostic.test.ts index 5daf6e5..fa6717f 100644 --- a/tests/e2e/push-result-diagnostic.test.ts +++ b/tests/e2e/push-result-diagnostic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { describePushResult } from '../../e2e/support/push-result-diagnostic'; +import { describePushResult } from '../../e2e-tests/provider/support/push-result-diagnostic'; describe('describePushResult', () => { it('includes provider errors in a failed push assertion', () => { diff --git a/tsconfig.json b/tsconfig.json index 2cdf5a6..8fc28e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,7 @@ "src/**/*.ts", "tests/**/*.ts", "vitest.config.ts", - "e2e/**/*.ts", + "e2e-tests/**/*.ts", "vitest.e2e.config.ts" ] } diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index b0083d5..c76f52f 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -3,28 +3,21 @@ import { defineConfig } from 'vitest/config'; // Separate from vitest.config.ts on purpose: `npm run test`/`npx vitest run` // must never be able to reach a real provider. This config is only ever // invoked via `npm run test:e2e -- --provider `, after -// `scripts/e2e-harness.sh provision` has generated the vitest-only runtime -// adapters this points at (E2E_RUNTIME_DIR) — see -// docs/testing/real-provider-e2e.md. Those adapters are what use -// fetch/globalThis/node:child_process; keeping them generated-not-committed -// is what keeps this checked-in config (and the suites it runs) clean of the -// APIs the Obsidian scanner flags. -const runtimeDir = process.env.E2E_RUNTIME_DIR; - +// `scripts/e2e-harness.sh provision` has resolved the run's isolated +// branch/container (E2E_WORKDIR) — see docs/testing/real-provider-e2e.md. export default defineConfig({ test: { environment: 'node', globals: true, // Real requestUrl shim, not the vi.fn() mock tests/setup.ts installs — // E2E suites need actual network calls to reach the provisioned provider. - alias: runtimeDir ? { - obsidian: `${runtimeDir}/obsidian-request-url.ts`, - '@e2e-runtime/git-verifier': `${runtimeDir}/verifier/git-verifier.ts`, - } : {}, + alias: { + obsidian: './e2e-tests/provider/runtime/obsidian-request-url.ts', + }, // Minimal `window` alias so production code written for Obsidian's // Electron renderer (e.g. window.setTimeout) runs as-is under Node. - setupFiles: runtimeDir ? [`${runtimeDir}/window-timers.ts`] : [], - include: ['e2e/suites/**/*.e2e.test.ts'], + setupFiles: ['./e2e-tests/provider/runtime/window-timers.ts'], + include: ['e2e-tests/provider/suites/**/*.e2e.test.ts'], exclude: ['**/node_modules/**', '**/.claude/**'], testTimeout: 120_000, hookTimeout: 120_000,