From 84c9d45b4212cf71c600348aad5b3fce3cab96b2 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Thu, 9 Jul 2026 00:58:12 +0200 Subject: [PATCH 1/2] test: headless integration harness for the assembled plugin (PR-1) Boot the real onload() against a fully-mocked Obsidian host + in-memory fake couch/auth/discovery routed through requestUrl, under vitest. No src/ changes - the only injection seams are the requestUrl mock and a property-shaped fake app; boot.ts is the single update point. One smoke proves the harness: connect (discovery -> DCR -> obsidian:// callback) -> pick memory -> first sync seeds the vault from couch -> re-sync does zero write HTTP. Fault knobs on fake-couch are wired now for the PR-2 scenario fan-out. --- package.json | 1 + test/fakes/boot.ts | 131 ++++++++++++++++++++ test/fakes/fake-auth-server.ts | 157 ++++++++++++++++++++++++ test/fakes/fake-couch.ts | 182 ++++++++++++++++++++++++++++ test/fakes/fake-secrets.ts | 32 +++++ test/fakes/fake-vault.ts | 90 ++++++++++++++ test/fakes/obsidian.ts | 104 ++++++++++++++++ test/fakes/router.ts | 102 ++++++++++++++++ test/integration/first-sync.test.ts | 65 ++++++++++ vitest.config.ts | 3 +- 10 files changed, 866 insertions(+), 1 deletion(-) create mode 100644 test/fakes/boot.ts create mode 100644 test/fakes/fake-auth-server.ts create mode 100644 test/fakes/fake-couch.ts create mode 100644 test/fakes/fake-secrets.ts create mode 100644 test/fakes/fake-vault.ts create mode 100644 test/fakes/obsidian.ts create mode 100644 test/fakes/router.ts create mode 100644 test/integration/first-sync.test.ts diff --git a/package.json b/package.json index 805d1a6..a6d0070 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "format": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "test": "vitest run", + "test:e2e": "vitest run test/integration", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "clean": "rm -rf main.js *.map coverage", diff --git a/test/fakes/boot.ts b/test/fakes/boot.ts new file mode 100644 index 0000000..140cbb0 --- /dev/null +++ b/test/fakes/boot.ts @@ -0,0 +1,131 @@ +import { vi } from 'vitest'; +import type { App, PluginManifest, Platform as PlatformType } from 'obsidian'; +import * as obsidian from 'obsidian'; +import { promises as fs } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FakeVault } from './fake-vault'; +import { fakeSecrets, type FakeSecrets } from './fake-secrets'; +import { FakeCouch } from './fake-couch'; +import { FakeAuthServer, type FakeMemory } from './fake-auth-server'; +import { Router } from './router'; +import { requestUrlMock, makeFakeApp } from './obsidian'; + +// bootPlugin(): assemble the REAL AgentageMemoryPlugin against the fully-mocked host + fakes and +// run its actual onload(). The ONLY injection seams are requestUrl (mocked) + the property-shaped +// fake app - main.ts has no DI constructor, so this file is the single point that must change if +// the plugin's Obsidian wiring changes. Returns handles + a teardown. + +export interface BootOptions { + fqdn?: string; + files?: Record; + memories?: FakeMemory[]; + memoryName?: string; // the couch-channel memory the resolution advertises + couchDb?: string; + desktop?: boolean; // Platform.isDesktopApp; false -> obsidian:// deep-link sign-in path +} + +export interface Handles { + plugin: import('../../src/main').default; + couch: FakeCouch; + auth: FakeAuthServer; + vault: FakeVault; + router: Router; + secrets: FakeSecrets; + configDir: string; + /** URLs passed to window.open (the authorize redirect). */ + openedUrls: string[]; + teardown: () => Promise; +} + +type WindowStub = { + open: (url: string) => void; + setInterval: (fn: () => void, ms: number) => number; + clearInterval: (id: number) => void; + setTimeout: (fn: () => void, ms: number) => number; + clearTimeout: (id: number) => void; +}; + +export async function bootPlugin(opts: BootOptions = {}): Promise { + const fqdn = opts.fqdn ?? 'test.local'; + const memoryName = opts.memoryName ?? 'work'; + const couchDb = opts.couchDb ?? 'mem_work'; + + const vault = new FakeVault(opts.files ?? {}); + const secrets = fakeSecrets(); + const couch = new FakeCouch(couchDb); + const auth = new FakeAuthServer({ memories: opts.memories ?? [], couchDb }); + const router = new Router({ fqdn, auth, couch, memoryName }); + + requestUrlMock.mockImplementation(router.requestUrl); + + // A throwaway config dir so auth.json + vaults.json writes never touch ~/.agentage. + const configDir = await fs.mkdtemp(join(tmpdir(), 'ams-e2e-')); + const prevConfigDir = process.env.AGENTAGE_CONFIG_DIR; + const prevSiteFqdn = process.env.AGENTAGE_SITE_FQDN; + process.env.AGENTAGE_CONFIG_DIR = configDir; + process.env.AGENTAGE_SITE_FQDN = fqdn; + + // Desktop drives loopback (a real node:http listener); the deep-link path is simpler + fully + // in-memory, so the smoke forces isDesktopApp=false and drives handleCallback via the router. + const desktop = opts.desktop ?? false; + (obsidian.Platform as unknown as typeof PlatformType & { isDesktopApp: boolean }).isDesktopApp = + desktop; + + const openedUrls: string[] = []; + const timers = new Set>(); + const windowStub: WindowStub = { + open: (url: string) => void openedUrls.push(url), + setInterval: (fn, ms) => { + const id = setInterval(fn, ms); + timers.add(id); + return id as unknown as number; + }, + clearInterval: (id) => clearInterval(id as unknown as ReturnType), + setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, + clearTimeout: (id) => clearTimeout(id as unknown as ReturnType), + }; + const prevWindow = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = windowStub; + + const { default: AgentageMemoryPlugin } = await import('../../src/main'); + const app = makeFakeApp(vault, secrets) as unknown as App; + const manifest = { id: 'agentage-memory', version: '0.0.0' } as unknown as PluginManifest; + const plugin = new AgentageMemoryPlugin(app, manifest); + await plugin.onload(); + + const teardown = async (): Promise => { + for (const id of timers) clearInterval(id); + (globalThis as { window?: unknown }).window = prevWindow; + if (prevConfigDir === undefined) delete process.env.AGENTAGE_CONFIG_DIR; + else process.env.AGENTAGE_CONFIG_DIR = prevConfigDir; + if (prevSiteFqdn === undefined) delete process.env.AGENTAGE_SITE_FQDN; + else process.env.AGENTAGE_SITE_FQDN = prevSiteFqdn; + requestUrlMock.mockReset(); + await fs.rm(configDir, { recursive: true, force: true }); + }; + + return { plugin, couch, auth, vault, router, secrets, configDir, openedUrls, teardown }; +} + +// Drive a full obsidian:// sign-in against the fakes: startSignIn -> capture the authorize URL -> +// mint a code at the fake AS -> feed the callback back through the plugin's protocol handler. +export async function signIn(h: Handles): Promise { + type SignInPlugin = { + openSignIn: () => void; + auth: { handleCallback: (p: Record) => Promise }; + }; + const p = h.plugin as unknown as SignInPlugin; + const before = h.openedUrls.length; + p.openSignIn(); + await vi.waitFor(() => { + if (h.openedUrls.length <= before) throw new Error('no authorize URL yet'); + }); + const authorizeUrl = new URL(h.openedUrls[h.openedUrls.length - 1]); + const clientId = authorizeUrl.searchParams.get('client_id') ?? ''; + const state = authorizeUrl.searchParams.get('state') ?? ''; + const codeChallenge = authorizeUrl.searchParams.get('code_challenge') ?? ''; + const redirectUri = authorizeUrl.searchParams.get('redirect_uri') ?? ''; + const { code } = h.auth.authorize({ clientId, codeChallenge, state, redirectUri }); + await p.auth.handleCallback({ code, state }); +} diff --git a/test/fakes/fake-auth-server.ts b/test/fakes/fake-auth-server.ts new file mode 100644 index 0000000..1493c8d --- /dev/null +++ b/test/fakes/fake-auth-server.ts @@ -0,0 +1,157 @@ +// In-memory Better Auth AS + management API: OAuth 2.1 discovery / DCR / token / refresh / +// revoke, the couch-token minter, and GET/POST /api/memories. Contracts mirror the real +// wire the plugin's src/auth/* + src/couch/couch-token + main.ts:listVaults speak. + +export interface FakeMemory { + name: string; + entries: number; + folderCount: number; + updated: string | null; +} + +export interface AuthReply { + status: number; + json: unknown; +} + +interface IssuedTokens { + access: string; + refresh: string; + clientId: string; +} + +export class FakeAuthServer { + private clients = new Set(); + private codes = new Map(); + private tokens = new Map(); // accessToken -> issued + private refreshIndex = new Map(); // refreshToken -> accessToken + private clientSeq = 0; + private tokenSeq = 0; + readonly memories: FakeMemory[]; + readonly couchDb: string; + private failNextStatus?: number; + + constructor(opts: { memories?: FakeMemory[]; couchDb?: string } = {}) { + this.memories = opts.memories ?? []; + this.couchDb = opts.couchDb ?? 'mem_default'; + } + + failNext(status: number): void { + this.failNextStatus = status; + } + + /** Is `access` a live (unrevoked) access token? Used by the api + couch-token guards. */ + isValidAccess(access: string): boolean { + return this.tokens.has(access); + } + + /** Drive the authorize step: mint a one-time code bound to the client + PKCE challenge. */ + authorize(params: { + clientId: string; + codeChallenge: string; + state: string; + redirectUri: string; + }): { code: string; state: string } { + if (!this.clients.has(params.clientId)) throw new Error('authorize: unknown client'); + const code = `code-${++this.tokenSeq}`; + this.codes.set(code, { + clientId: params.clientId, + challenge: params.codeChallenge, + redirectUri: params.redirectUri, + }); + return { code, state: params.state }; + } + + private issue(clientId: string): AuthReply { + const access = `at-${++this.tokenSeq}`; + const refresh = `rt-${this.tokenSeq}`; + this.tokens.set(access, { access, refresh, clientId }); + this.refreshIndex.set(refresh, access); + return { + status: 200, + json: { access_token: access, refresh_token: refresh, expires_in: 3600 }, + }; + } + + handle(method: string, url: string, body?: string): AuthReply { + if (this.failNextStatus !== undefined) { + const status = this.failNextStatus; + this.failNextStatus = undefined; + return { status, json: { error: 'scripted failure' } }; + } + const { pathname } = new URL(url); + if (pathname.endsWith('/.well-known/oauth-authorization-server')) return this.discovery(url); + if (pathname.endsWith('/register')) return this.register(); + if (pathname.endsWith('/token')) return this.token(body); + if (pathname.endsWith('/revoke')) return { status: 200, json: {} }; + if (pathname.endsWith('/account/couch-token')) return this.couchToken(body); + return { status: 404, json: { error: 'not_found' } }; + } + + private discovery(url: string): AuthReply { + const origin = new URL(url).origin; + return { + status: 200, + json: { + issuer: origin, + authorization_endpoint: `${origin}/api/auth/mcp/authorize`, + token_endpoint: `${origin}/api/auth/mcp/token`, + registration_endpoint: `${origin}/api/auth/mcp/register`, + revocation_endpoint: `${origin}/api/auth/mcp/revoke`, + }, + }; + } + + private register(): AuthReply { + const clientId = `client-${++this.clientSeq}`; + this.clients.add(clientId); + return { status: 201, json: { client_id: clientId } }; + } + + private token(body?: string): AuthReply { + const p = new URLSearchParams(body ?? ''); + const grant = p.get('grant_type'); + if (grant === 'authorization_code') { + const code = p.get('code') ?? ''; + const rec = this.codes.get(code); + if (!rec) return { status: 400, json: { error: 'invalid_grant' } }; + this.codes.delete(code); + return this.issue(rec.clientId); + } + if (grant === 'refresh_token') { + const rt = p.get('refresh_token') ?? ''; + const priorAccess = this.refreshIndex.get(rt); + if (!priorAccess) return { status: 400, json: { error: 'invalid_grant' } }; + const prior = this.tokens.get(priorAccess); + this.refreshIndex.delete(rt); + if (prior) this.tokens.delete(prior.access); // rotate: the old access token dies + return this.issue(prior?.clientId ?? p.get('client_id') ?? 'client'); + } + return { status: 400, json: { error: 'unsupported_grant_type' } }; + } + + private couchToken(body?: string): AuthReply { + const memory = (JSON.parse(body ?? '{}') as { memory?: string }).memory ?? 'default'; + return { + status: 200, + json: { + success: true, + data: { jwt: `couch-jwt-${memory}`, db: this.couchDb, sub: `u/${memory}`, expSec: 3600 }, + }, + }; + } + + /** GET /api/memories -> { data:[{name,entries,folderCount,updated}] }. */ + listMemories(): AuthReply { + return { status: 200, json: { data: this.memories } }; + } + + /** POST /api/memories { name } -> 201 (adds an empty memory). */ + createMemory(body?: string): AuthReply { + const name = (JSON.parse(body ?? '{}') as { name?: string }).name ?? ''; + if (!name) return { status: 400, json: { error: { message: 'name required' } } }; + if (!this.memories.some((m) => m.name === name)) + this.memories.push({ name, entries: 0, folderCount: 0, updated: null }); + return { status: 201, json: { data: { name } } }; + } +} diff --git a/test/fakes/fake-couch.ts b/test/fakes/fake-couch.ts new file mode 100644 index 0000000..ac59465 --- /dev/null +++ b/test/fakes/fake-couch.ts @@ -0,0 +1,182 @@ +import type { FileDoc, LeafDoc } from '../../src/couch/couch-doc'; + +// An in-memory CouchDB matching the exact wire contract src/couch/couch-sync.ts speaks: +// - docs keyed by id: file docs `f:` + leaf docs `h:`, each with a _rev +// - an ordered changes log with a monotonic integer seq (stringified as last_seq) +// - GET / -> doc (200) or 404 +// - POST /_bulk_docs -> [] (leaves accepted) or [{id,error}] (scripted leaf failure) +// - PUT /f: -> upsert + bump _rev + append a change; 409 on a stale _rev +// - DELETE /f:?rev -> 409 if stale, else tombstone + a deleted change +// - GET /_changes?since=&limit= -> a page slice + last_seq +// Fault knobs (failNext / unauthorizeUntilRemint / dropLeaf / injectRemoteChange) are wired +// now for the PR-2 scenario fan-out, exercised by the smoke only through the happy path. + +type StoredDoc = (FileDoc | LeafDoc) & { _rev: string; _deleted?: boolean }; +interface ChangeRow { + seq: number; + id: string; + rev: string; + deleted: boolean; +} + +export interface CouchReply { + status: number; + json: unknown; +} + +const rev = (n: number, tag: string): string => `${n}-${tag.slice(0, 8).padStart(8, '0')}`; +const revNum = (r: string): number => Number(r.split('-')[0]) || 0; + +export class FakeCouch { + private docs = new Map(); + private changes: ChangeRow[] = []; + private seq = 0; + private failStatus?: number; + private unauthorizeOnce = false; + private droppedLeaves = new Set(); + private failLeaf?: { id: string; error: string }; + + constructor(readonly db: string) {} + + // -- fault knobs (PR-2) ----------------------------------------------------- + /** The next request (any) returns this status once, then normal service resumes. */ + failNext(status: number): void { + this.failStatus = status; + } + /** Return 401 once (as an expired couch JWT would), forcing the client to re-mint + retry. */ + unauthorizeUntilRemint(): void { + this.unauthorizeOnce = true; + } + /** Make a leaf GET 404 even though the file doc references it (truncation-guard test). */ + dropLeaf(id: string): void { + this.droppedLeaves.add(id); + } + /** Script the next _bulk_docs to report a per-leaf failure for `id`. */ + failLeafOnBulk(id: string, error = 'forbidden'): void { + this.failLeaf = { id, error }; + } + /** Seed a remote-origin change (as another device would) so a pull delivers it. */ + injectRemoteChange(path: string, body: string, leaves: LeafDoc[]): void { + for (const l of leaves) this.docs.set(l._id, { ...l, _rev: l._rev || rev(1, l._id.slice(2)) }); + const id = `f:${path}`; + const prev = this.docs.get(id); + const n = prev ? revNum(prev._rev) + 1 : 1; + const doc: StoredDoc = { + _id: id, + _rev: rev(n, path + body.length), + type: 'file', + path, + size: body.length, + leaves: leaves.map((l) => l._id), + }; + this.docs.set(id, doc); + this.append(id, doc._rev, false); + } + + // -- assertion helpers ------------------------------------------------------ + fileDoc(path: string): FileDoc | undefined { + const d = this.docs.get(`f:${path}`); + return d && d._deleted !== true && (d as FileDoc).type === 'file' ? (d as FileDoc) : undefined; + } + hasLeaf(id: string): boolean { + return this.docs.has(id); + } + filePaths(): string[] { + return [...this.docs.values()] + .filter((d) => (d as FileDoc).type === 'file' && !d._deleted) + .map((d) => (d as FileDoc).path) + .sort(); + } + lastSeq(): number { + return this.seq; + } + + private append(id: string, r: string, deleted: boolean): void { + // Latest-wins per id (CouchDB collapses superseded rows), so drop any prior row for this id. + this.changes = this.changes.filter((c) => c.id !== id); + this.changes.push({ seq: ++this.seq, id, rev: r, deleted }); + } + + // -- request dispatch ------------------------------------------------------- + /** Handle one couch request; `path` is everything after `/`. */ + handle(method: string, path: string, body?: string): CouchReply { + if (this.unauthorizeOnce) { + this.unauthorizeOnce = false; + return { status: 401, json: { error: 'unauthorized' } }; + } + if (this.failStatus !== undefined) { + const status = this.failStatus; + this.failStatus = undefined; + return { status, json: { error: 'scripted failure' } }; + } + const [rawId, query = ''] = path.replace(/^\//, '').split('?'); + const id = decodeURIComponent(rawId); + if (id === '_bulk_docs') return this.bulkDocs(body); + if (id === '_changes') return this.changesFeed(query); + if (method === 'GET') return this.getDoc(id); + if (method === 'PUT') return this.putDoc(id, body); + if (method === 'DELETE') return this.deleteDoc(id, query); + return { status: 405, json: { error: 'method not allowed' } }; + } + + private getDoc(id: string): CouchReply { + if (id.startsWith('h:') && this.droppedLeaves.has(id)) return { status: 404, json: {} }; + const d = this.docs.get(id); + if (!d || d._deleted) return { status: 404, json: { error: 'not_found' } }; + return { status: 200, json: d }; + } + + private bulkDocs(body?: string): CouchReply { + const parsed = JSON.parse(body ?? '{}') as { docs?: LeafDoc[] }; + for (const leaf of parsed.docs ?? []) { + if (this.failLeaf && this.failLeaf.id === leaf._id) { + const err = this.failLeaf; + this.failLeaf = undefined; + return { status: 200, json: [{ id: err.id, error: err.error }] }; + } + if (!this.docs.has(leaf._id)) + this.docs.set(leaf._id, { ...leaf, _rev: leaf._rev || rev(1, leaf._id.slice(2)) }); + } + return { status: 200, json: [] }; // new_edits:false -> no per-doc rows on success + } + + private putDoc(id: string, body?: string): CouchReply { + const put = JSON.parse(body ?? '{}') as FileDoc; + const cur = this.docs.get(id); + if (cur && !cur._deleted && cur._rev !== put._rev) + return { status: 409, json: { error: 'conflict' } }; + const n = cur ? revNum(cur._rev) + 1 : 1; + const stored: StoredDoc = { ...put, _rev: rev(n, id + (put.leaves?.join('') ?? '')) }; + this.docs.set(id, stored); + this.append(id, stored._rev, false); + return { status: 201, json: { ok: true, id, rev: stored._rev } }; + } + + private deleteDoc(id: string, query: string): CouchReply { + const revParam = new URLSearchParams(query).get('rev'); + const cur = this.docs.get(id); + if (!cur || cur._deleted) return { status: 404, json: { error: 'not_found' } }; + if (revParam !== cur._rev) return { status: 409, json: { error: 'conflict' } }; + const n = revNum(cur._rev) + 1; + const tomb: StoredDoc = { ...cur, _rev: rev(n, id + 'del'), _deleted: true }; + this.docs.set(id, tomb); + this.append(id, tomb._rev, true); + return { status: 200, json: { ok: true, id, rev: tomb._rev } }; + } + + private changesFeed(query: string): CouchReply { + const q = new URLSearchParams(query); + const since = Number(q.get('since') ?? '0') || 0; + const limit = Number(q.get('limit') ?? '200') || 200; + const rows = this.changes.filter((c) => c.seq > since).sort((a, b) => a.seq - b.seq); + const page = rows.slice(0, limit); + const results = page.map((c) => { + const doc = this.docs.get(c.id) as FileDoc | undefined; + return c.deleted + ? { id: c.id, deleted: true, changes: [{ rev: c.rev }] } + : { id: c.id, changes: [{ rev: c.rev }], doc }; + }); + const last = page.length ? page[page.length - 1].seq : since; + return { status: 200, json: { results, last_seq: String(last) } }; + } +} diff --git a/test/fakes/fake-secrets.ts b/test/fakes/fake-secrets.ts new file mode 100644 index 0000000..e9a5f86 --- /dev/null +++ b/test/fakes/fake-secrets.ts @@ -0,0 +1,32 @@ +// Lifted from src/auth/token-store.test.ts, extended with a throwing mode so a harness test +// can exercise main.ts's secretStorage-unavailable fallback (in-memory cache + auth.json). + +export interface FakeSecretStorage { + getSecret(id: string): string | null; + setSecret(id: string, value: string): void; +} + +export interface FakeSecrets extends FakeSecretStorage { + /** Make every getSecret/setSecret throw, as a keyring-less desktop would. */ + breakKeyring(): void; + /** Direct peek into the backing map (bypasses the throwing mode) for assertions. */ + peek(id: string): string | null; +} + +// Shaped like Obsidian's app.secretStorage (getSecret/setSecret), which main.ts reads off app. +export const fakeSecrets = (): FakeSecrets => { + const m = new Map(); + let broken = false; + return { + getSecret: (id) => { + if (broken) throw new Error('secretStorage unavailable'); + return m.get(id) ?? null; + }, + setSecret: (id, value) => { + if (broken) throw new Error('secretStorage unavailable'); + m.set(id, value); + }, + breakKeyring: () => void (broken = true), + peek: (id) => m.get(id) ?? null, + }; +}; diff --git a/test/fakes/fake-vault.ts b/test/fakes/fake-vault.ts new file mode 100644 index 0000000..c4ddd74 --- /dev/null +++ b/test/fakes/fake-vault.ts @@ -0,0 +1,90 @@ +import { TFile } from 'obsidian'; + +// Lifted from src/couch/couch-sync.test.ts and extended with a real event emitter so the +// assembled plugin's live handlers (vault.on 'create'/'modify'/'delete'/'rename') can be +// driven, and with getName/adapter so main.ts's vault-name + root-path helpers work. + +export type VaultEvent = 'create' | 'modify' | 'delete' | 'rename'; +type Listener = (file: TFile, oldPath?: string) => void; + +const mkFile = (path: string): TFile => + Object.assign(new TFile(), { path, extension: path.split('.').pop() ?? '' }) as unknown as TFile; + +export class FakeVault { + private files = new Map(); + private listeners = new Map>(); + modifyCalls = 0; + createCalls = 0; + deleteCalls = 0; + + constructor( + init: Record = {}, + private name = 'test-vault' + ) { + for (const [p, c] of Object.entries(init)) this.files.set(p, { file: mkFile(p), content: c }); + } + + getName(): string { + return this.name; + } + + // main.ts reads adapter.getBasePath() only when the adapter is a FileSystemAdapter; ours is + // not, so it falls back to getName() - a plain object is enough to satisfy the property read. + readonly adapter = {} as unknown; + + getAbstractFileByPath(p: string): TFile | null { + return this.files.get(p)?.file ?? null; + } + async read(f: TFile): Promise { + return this.files.get(f.path)?.content ?? ''; + } + getMarkdownFiles(): TFile[] { + return [...this.files.values()].map((v) => v.file).filter((f) => f.extension === 'md'); + } + async modify(f: TFile, data: string): Promise { + this.modifyCalls++; + this.files.set(f.path, { file: f, content: data }); + } + async create(p: string, data: string): Promise { + this.createCalls++; + const file = mkFile(p); + this.files.set(p, { file, content: data }); + return file; + } + async createFolder(_p: string): Promise {} + async delete(f: TFile): Promise { + this.deleteCalls++; + this.files.delete(f.path); + } + + content(p: string): string | undefined { + return this.files.get(p)?.content; + } + paths(): string[] { + return [...this.files.keys()].sort(); + } + + // --- event emitter (Obsidian's EventRef surface is unused by main.ts's registerEvent) --- + on(event: VaultEvent, cb: Listener): { event: VaultEvent } { + (this.listeners.get(event) ?? this.listeners.set(event, new Set()).get(event)!).add(cb); + return { event }; + } + + /** Drive a vault event AND apply it to the in-memory store, mirroring a real user edit. */ + trigger(event: VaultEvent, path: string, content?: string, oldPath?: string): void { + if (event === 'create' && content !== undefined) + this.files.set(path, { file: mkFile(path), content }); + if (event === 'modify' && content !== undefined) { + const cur = this.files.get(path); + this.files.set(path, { file: cur?.file ?? mkFile(path), content }); + } + if (event === 'delete') this.files.delete(path); + if (event === 'rename' && oldPath) { + const prev = this.files.get(oldPath); + this.files.delete(oldPath); + this.files.set(path, { file: mkFile(path), content: prev?.content ?? content ?? '' }); + } + const file = mkFile(path); + for (const cb of this.listeners.get(event) ?? []) cb(file, oldPath); + } +} diff --git a/test/fakes/obsidian.ts b/test/fakes/obsidian.ts new file mode 100644 index 0000000..9725112 --- /dev/null +++ b/test/fakes/obsidian.ts @@ -0,0 +1,104 @@ +import { vi } from 'vitest'; +import type { FakeVault } from './fake-vault'; +import type { FakeSecretStorage } from './fake-secrets'; + +// The vi.mock('obsidian') factory (lifted from src/main.test.ts) plus a property-shaped fake +// App/Plugin base + a chainable HTMLElement/Setting so the REAL main.ts onload() (which builds +// the status bar and re-renders the settings tab) runs headless. requestUrl is a vi.fn the boot +// wires to the Router afterward (hoist-safe, the colocated-test pattern). + +// The shared requestUrl spy - boot points it at Router.requestUrl via mockImplementation. +export const requestUrlMock = vi.fn(); + +// A self-returning chainable stub: every method returns itself and every unknown property is +// another chainable, so the plugin's fluent DOM/Setting builders (createEl().setText(), +// addButton(b => b.setButtonText().onClick())) run as no-ops without modelling the real DOM. +const chainable = (): unknown => { + const target = function () {} as unknown as Record; + const handler: ProxyHandler> = { + get(_t, prop) { + if (prop === 'text') return ''; + return proxy; + }, + apply() { + return proxy; + }, + }; + const proxy: unknown = new Proxy(target, handler); + return proxy; +}; + +/** The vi.mock('obsidian') factory. Also drives Platform.isDesktopApp so a test can flip it. */ +export const obsidianMockFactory = () => { + class TFile { + path = ''; + extension = ''; + } + return { + FileSystemAdapter: class FileSystemAdapter {}, + Menu: class Menu { + addItem() { + return this; + } + showAtMouseEvent() {} + }, + // Inert modal: open()/close() no-op (the base never invokes the subclass onOpen), so the + // post-sign-in sync popup does not fire a second, timing-dependent sync in the harness. + Modal: class Modal { + contentEl = chainable(); + constructor(_app: unknown) {} + open() {} + close() {} + }, + Notice: class Notice {}, + Platform: { isDesktopApp: true }, + Plugin: class Plugin { + app: unknown; + manifest: unknown; + constructor(app: unknown, manifest: unknown) { + this.app = app; + this.manifest = manifest; + } + addSettingTab() {} + addRibbonIcon() { + return chainable(); + } + addStatusBarItem() { + return chainable(); + } + addCommand() {} + registerObsidianProtocolHandler() {} + registerEvent() {} + registerInterval() {} + registerDomEvent() {} + loadData() { + return Promise.resolve(null); + } + saveData() { + return Promise.resolve(); + } + }, + PluginSettingTab: class PluginSettingTab { + containerEl = chainable(); + constructor(_app: unknown, _plugin: unknown) {} + }, + FuzzySuggestModal: class FuzzySuggestModal {}, + // A chainable no-op builder: new Setting(el).setName().addButton(b => b.onClick())… + Setting: class Setting { + constructor(_containerEl: unknown) { + return chainable() as Setting; + } + }, + TFile, + requestUrl: requestUrlMock, + normalizePath: (p: string) => p, + debounce: (fn: unknown) => fn, + }; +}; + +/** A property-shaped fake Obsidian App: the vault event emitter + the secretStorage shim. */ +export const makeFakeApp = (vault: FakeVault, secretStorage: FakeSecretStorage): unknown => ({ + vault, + secretStorage, + setting: { open: () => {}, openTabById: () => {} }, +}); diff --git a/test/fakes/router.ts b/test/fakes/router.ts new file mode 100644 index 0000000..12f6645 --- /dev/null +++ b/test/fakes/router.ts @@ -0,0 +1,102 @@ +import type { RequestUrlParam, RequestUrlResponse } from 'obsidian'; +import { FakeCouch } from './fake-couch'; +import { FakeAuthServer } from './fake-auth-server'; + +// The single requestUrl seam: every HTTP call the assembled plugin makes goes through here. +// Dispatch by host to the right fake (sync. discovery, auth. AS + couch-token, api. memories, +// couch. the in-memory CouchDB). Counts calls so a test can assert "re-sync is zero-HTTP". + +export interface RouterOptions { + fqdn: string; // active site fqdn, e.g. 'test.local' + auth: FakeAuthServer; + couch: FakeCouch; + memoryName: string; // the couch-channel memory the resolution advertises +} + +export class Router { + calls: Array<{ method: string; url: string }> = []; + private opts: RouterOptions; + + constructor(opts: RouterOptions) { + this.opts = opts; + } + + get syncOrigin(): string { + return `https://sync.${this.opts.fqdn}`; + } + get couchOrigin(): string { + return `https://couch.${this.opts.fqdn}`; + } + get authOrigin(): string { + return `https://auth.${this.opts.fqdn}`; + } + get apiOrigin(): string { + return `https://api.${this.opts.fqdn}`; + } + + callCount(): number { + return this.calls.length; + } + reset(): void { + this.calls = []; + } + + private reply(status: number, json: unknown): RequestUrlResponse { + const text = JSON.stringify(json); + return { + status, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json, + text, + } as unknown as RequestUrlResponse; + } + + // The couch resolution (GET sync./.well-known/agentage-sync). Advertises exactly one + // couch-channel memory pointing at this router's couch origin + couch-token url. + private resolution(): unknown { + return { + region: 'default', + ttl: 3600, + couch_endpoint: this.couchOrigin, + couch_token_url: `${this.authOrigin}/account/couch-token`, + couch_vaults: [{ vault: this.opts.memoryName, db: this.opts.couch.db }], + }; + } + + /** The vi.mock('obsidian').requestUrl implementation. */ + requestUrl = async (param: RequestUrlParam | string): Promise => { + const p = typeof param === 'string' ? { url: param } : param; + const method = p.method ?? 'GET'; + const url = p.url; + const body = typeof p.body === 'string' ? p.body : undefined; + this.calls.push({ method, url }); + const host = new URL(url).host; + + if (host === new URL(this.syncOrigin).host) return this.reply(200, this.resolution()); + + if (host === new URL(this.couchOrigin).host) { + const prefix = `${this.couchOrigin}/${this.opts.couch.db}`; + const rest = url.slice(prefix.length); + const r = this.opts.couch.handle(method, rest, body); + return this.reply(r.status, r.json); + } + + if (host === new URL(this.apiOrigin).host) { + if (url.includes('/api/memories')) + return method === 'POST' + ? this.replyAuth(this.opts.auth.createMemory(body)) + : this.replyAuth(this.opts.auth.listMemories()); + return this.reply(404, { error: 'not_found' }); + } + + if (host === new URL(this.authOrigin).host) + return this.replyAuth(this.opts.auth.handle(method, url, body)); + + return this.reply(404, { error: `no fake for host ${host}` }); + }; + + private replyAuth(r: { status: number; json: unknown }): RequestUrlResponse { + return this.reply(r.status, r.json); + } +} diff --git a/test/integration/first-sync.test.ts b/test/integration/first-sync.test.ts new file mode 100644 index 0000000..55c069a --- /dev/null +++ b/test/integration/first-sync.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { obsidianMockFactory } from '../fakes/obsidian'; + +// The whole plugin import graph resolves 'obsidian' to the harness mock (class bases + +// Platform + the requestUrl spy). Everything else the plugin uses is injected via the fake +// app + the Router behind requestUrl - no real Obsidian binary, no network. +vi.mock('obsidian', () => obsidianMockFactory()); + +import { bootPlugin, signIn, type Handles } from '../fakes/boot'; + +describe('first-sync smoke: connect -> pick memory -> seed vault -> zero-HTTP re-sync', () => { + let h: Handles; + + beforeEach(async () => { + // The server already holds one couch-channel memory 'work' with a seeded note, so the + // first sync PULLS remote content into an empty local vault (the wedge first-run flow). + h = await bootPlugin({ + files: {}, + memoryName: 'work', + couchDb: 'mem_work', + memories: [{ name: 'work', entries: 1, folderCount: 0, updated: '2026-07-08' }], + }); + h.couch.injectRemoteChange('welcome.md', 'Hello from the cloud', [ + { _id: 'h:seed', _rev: '1-seed', data: 'Hello from the cloud' }, + ]); + }); + + afterEach(async () => { + await h.teardown(); + }); + + it('signs in, selects the memory, first-syncs the seed note, then re-syncs with no HTTP', async () => { + // 1. Not signed in at boot. + expect(h.plugin.isSignedIn()).toBe(false); + + // 2. Connect: discovery -> DCR -> authorize -> code exchange (all through the router). + await signIn(h); + expect(h.plugin.isSignedIn()).toBe(true); + + // 3. Pick the memory + run the first sync (selectVault triggers autoSyncOnReady). + await h.plugin.selectVault('work'); + const result = await h.plugin.syncNow(); + + // 4. First sync succeeded and seeded the local vault from couch. + expect(result.ok).toBe(true); + expect(h.vault.content('welcome.md')).toBe('Hello from the cloud'); + expect(h.vault.paths()).toEqual(['welcome.md']); + + // 5. Fake-couch state agrees: the seeded file doc + its leaf exist server-side. + expect(h.couch.filePaths()).toEqual(['welcome.md']); + expect(h.couch.hasLeaf('h:seed')).toBe(true); + + // 6. Re-sync with nothing changed does ZERO write HTTP: the push rev-cache + pull cursor + // are warm, so the only traffic is the single empty _changes poll (no PUT/POST/DELETE). + h.router.reset(); + const again = await h.plugin.syncNow(); + expect(again.ok).toBe(true); + const writes = h.router.calls.filter((c) => c.method !== 'GET'); + expect(writes).toEqual([]); + // The one GET is the empty _changes poll, and it advertises the caught-up cursor. + const changes = h.router.calls.filter((c) => c.url.includes('/_changes')); + expect(changes).toHaveLength(1); + expect(changes[0].url).toContain(`since=${h.couch.lastSeq()}`); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index aa9edb0..94c242b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['src/**/*.test.ts'], + // src unit tests + the assembled-plugin integration harness (test/**) run in one pass. + include: ['src/**/*.test.ts', 'test/**/*.test.ts'], testTimeout: 30000, // couch replication tests exercise retry/backoff timing coverage: { provider: 'v8', From ac0c42ccfbeda1a6155ba1252d7014d53d3adc64 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Thu, 9 Jul 2026 01:06:03 +0200 Subject: [PATCH 2/2] chore: trigger CI