From ce3ee8ac2f59910422e12be375de2b6e326b531c Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 15:50:23 +0530 Subject: [PATCH 1/3] ENG-2143 Add Roam and Obsidian round-trip tests --- .../src/utils/__tests__/importNodes.test.ts | 307 +---------- .../src/utils/__tests__/importNodesHarness.ts | 289 ++++++++++ .../src/utils/__tests__/mocks/importNodes.ts | 18 + .../utils/__tests__/sourceRoundTrip.test.mjs | 500 ++++++++++++++++++ apps/obsidian/vitest.config.mts | 7 +- 5 files changed, 821 insertions(+), 300 deletions(-) create mode 100644 apps/obsidian/src/utils/__tests__/importNodesHarness.ts create mode 100644 apps/obsidian/src/utils/__tests__/mocks/importNodes.ts create mode 100644 apps/obsidian/src/utils/__tests__/sourceRoundTrip.test.mjs diff --git a/apps/obsidian/src/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts index ca75f8712..c77f31684 100644 --- a/apps/obsidian/src/utils/__tests__/importNodes.test.ts +++ b/apps/obsidian/src/utils/__tests__/importNodes.test.ts @@ -1,307 +1,16 @@ +import { + createHarness, + evidenceRid, + sourceRid, + selectedNode, +} from "./importNodesHarness"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import matter from "gray-matter"; -import { Notice, TFile } from "obsidian"; -import type DiscourseGraphPlugin from "~/index"; -import type { DGSupabaseClient } from "@repo/database/lib/client"; -import type { ImportableNode, RelationInstance } from "~/types"; +import { Notice } from "obsidian"; +import type { RelationInstance } from "~/types"; import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; -import { getLoggedInClient, getSupabaseContext } from "../supabaseContext"; import { importSelectedNodes, refreshImportedFile } from "../importNodes"; import { loadRelations, saveRelations } from "../relationsStore"; -vi.mock("../supabaseContext", () => ({ - getLoggedInClient: vi.fn(), - getSupabaseContext: vi.fn(), - getVaultId: () => "local-vault", - getLocalSpaceUri: () => "obsidian:local-vault", -})); -vi.mock("../publishNode", () => ({ - publishNewRelation: vi.fn().mockResolvedValue(false), -})); -vi.mock("../templates", () => ({ createTemplateFile: vi.fn() })); -vi.mock("../importFolderMetadata", () => ({ - resolveFolderForSpaceUri: vi.fn().mockResolvedValue("import/Research"), -})); -vi.mock("../importRelations", () => ({ - importRelationsForImportedNodes: vi.fn().mockResolvedValue({ imported: 0 }), -})); - -const REMOTE_URI = "https://roamresearch.com/#/app/research"; -const evidenceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "evidence", "note"); -const sourceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "source", "note"); -const selectedNode: ImportableNode = { - nodeInstanceId: "evidence", - title: "Evidence title", - spaceId: 2, - spaceName: "Research", - groupId: "group", - selected: true, -}; - -type Row = Record; -const createHarness = () => { - const files = new Map(); - const contents = new Map(); - const schemas: Row[] = [ - { - id: 10, - space_id: 2, - source_local_id: "evidence-type", - name: "Evidence", - is_schema: true, - is_relation: false, - literal_content: {}, - }, - { - id: 11, - space_id: 2, - source_local_id: "source-type", - name: "Source", - is_schema: true, - is_relation: false, - literal_content: {}, - }, - ]; - const concepts: Row[] = [ - ...schemas, - { - id: 20, - space_id: 2, - source_local_id: "evidence", - schema_id: 10, - core_title: "Evidence title", - sourceDocument: 21, - is_schema: false, - is_relation: false, - }, - { - id: 21, - space_id: 2, - source_local_id: "source", - schema_id: 11, - core_title: "Source title", - sourceDocument: null, - is_schema: false, - is_relation: false, - }, - ]; - const contentRows: Row[] = ["evidence", "source"].flatMap((id) => [ - { - space_id: 2, - source_local_id: id, - variant: "direct", - text: `${id} incoming title`, - metadata: {}, - author_id: 1, - created: "2026-09-01T00:00:00", - last_modified: "2026-09-02T00:00:00", - }, - { - space_id: 2, - source_local_id: id, - variant: "full", - text: `${id} body`, - metadata: {}, - author_id: 1, - created: "2026-09-01T00:00:00", - last_modified: "2026-09-02T00:00:00", - }, - ]); - const spaces: Row[] = [ - { id: 1, url: "obsidian:local-vault", name: "Local" }, - { id: 2, url: REMOTE_URI, name: "Research" }, - ]; - const requests: { - table: string; - select: string; - filters: [string, unknown][]; - }[] = []; - let sourceQueryError = false; - const from = vi.fn((table: string) => { - const request = { table, select: "", filters: [] as [string, unknown][] }; - requests.push(request); - const rows = (): Row[] => { - const tableRows = - table === "my_concepts" - ? concepts - : table === "my_contents" || table === "Content" - ? contentRows - : table === "my_spaces" || table === "Space" - ? spaces - : []; - return tableRows.filter((row) => - request.filters.every(([key, value]) => - Array.isArray(value) ? value.includes(row[key]) : row[key] === value, - ), - ); - }; - const response = (single: boolean) => ({ - data: single ? (rows()[0] ?? null) : rows(), - error: - sourceQueryError && - table === "my_concepts" && - request.select === "id, source_local_id, space_id" - ? { message: "Source lookup failed" } - : null, - }); - const query = { - select: (columns: string) => { - request.select = columns; - return query; - }, - eq: (key: string, value: unknown) => { - request.filters.push([key, value]); - return query; - }, - in: (key: string, values: unknown[]) => { - request.filters.push([key, values]); - return query; - }, - maybeSingle: () => Promise.resolve(response(true)), - then: (resolve: (result: ReturnType) => unknown) => - Promise.resolve(response(false)).then(resolve), - }; - return query; - }); - const create = vi.fn((path: string, content: string) => { - if (files.has(path)) - return Promise.reject(new Error(`File already exists: ${path}`)); - const file = new TFile(); - file.path = path; - files.set(path, file); - contents.set(path, content); - return Promise.resolve(file); - }); - const renameFile = vi.fn((file: TFile, newPath: string) => { - const content = contents.get(file.path)!; - files.delete(file.path); - contents.delete(file.path); - file.path = newPath; - files.set(newPath, file); - contents.set(newPath, content); - return Promise.resolve(); - }); - const getFileCache = vi.fn((file: TFile) => ({ - frontmatter: matter(contents.get(file.path) ?? "").data, - })); - const saveSettings = vi.fn(); - const plugin = { - app: { - plugins: { plugins: {} }, - vault: { - create, - getFileByPath: (path: string) => files.get(path) ?? null, - getAbstractFileByPath: (path: string) => files.get(path) ?? null, - getMarkdownFiles: () => - [...files.values()].filter((file) => file.extension === "md"), - read: (file: TFile) => Promise.resolve(contents.get(file.path)!), - modify: (file: TFile, content: string) => { - contents.set(file.path, content); - return Promise.resolve(); - }, - process: (file: TFile, callback: (content: string) => string) => { - contents.set(file.path, callback(contents.get(file.path)!)); - return Promise.resolve(); - }, - createFolder: vi.fn(), - adapter: { exists: (path: string) => Promise.resolve(files.has(path)) }, - }, - metadataCache: { getFileCache, getFirstLinkpathDest: () => null }, - fileManager: { - renameFile, - processFrontMatter: ( - file: TFile, - callback: (frontmatter: Row) => void, - ) => { - const parsed = matter(contents.get(file.path)!); - callback(parsed.data); - contents.set( - file.path, - matter.stringify(parsed.content, parsed.data), - ); - return Promise.resolve(); - }, - }, - }, - settings: { - nodeTypes: [ - { - id: "evidence-type", - name: "Evidence", - format: "EVD - {content}", - created: 0, - modified: 0, - }, - { - id: "source-type", - name: "Source", - format: "SRC - {content}", - created: 0, - modified: 0, - }, - ], - relationTypes: [ - { - id: "based-on", - label: "Based on", - complement: "Source of", - color: "black", - created: 0, - modified: 0, - }, - ], - discourseRelations: [ - { - id: "evidence-source", - sourceId: "evidence-type", - destinationId: "source-type", - relationshipTypeId: "based-on", - created: 0, - modified: 0, - }, - ], - }, - saveSettings, - } as unknown as DiscourseGraphPlugin; - vi.mocked(getLoggedInClient).mockResolvedValue({ - from, - } as unknown as DGSupabaseClient); - vi.mocked(getSupabaseContext).mockResolvedValue({ - spaceId: 1, - platform: "Obsidian", - userId: 1, - spacePassword: "test", - }); - const pull = () => - importSelectedNodes({ plugin, selectedNodes: [selectedNode] }); - const seedSource = async (local = false): Promise => - create( - "My existing source.md", - matter.stringify("Original source body", { - nodeInstanceId: "source", - nodeTypeId: "source-type", - ...(local ? {} : { importedFromRid: sourceRid }), - }), - ); - return { - plugin, - saveSettings, - concepts, - contentRows, - requests, - files, - contents, - create, - renameFile, - getFileCache, - pull, - seedSource, - failSourceQuery: () => { - sourceQueryError = true; - }, - }; -}; - beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "warn").mockImplementation(() => undefined); diff --git a/apps/obsidian/src/utils/__tests__/importNodesHarness.ts b/apps/obsidian/src/utils/__tests__/importNodesHarness.ts new file mode 100644 index 000000000..29d92512c --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/importNodesHarness.ts @@ -0,0 +1,289 @@ +import { vi } from "vitest"; +import matter from "gray-matter"; +import { TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { ImportableNode } from "~/types"; +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import { getLoggedInClient, getSupabaseContext } from "../supabaseContext"; +import { importSelectedNodes } from "../importNodes"; + +export const REMOTE_URI = "https://roamresearch.com/#/app/research"; +export const evidenceRid = spaceUriAndLocalIdToRid( + REMOTE_URI, + "evidence", + "note", +); +export const sourceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "source", "note"); +export const selectedNode: ImportableNode = { + nodeInstanceId: "evidence", + title: "Evidence title", + spaceId: 2, + spaceName: "Research", + groupId: "group", + selected: true, +}; + +type Row = Record; +export const createHarness = () => { + const files = new Map(); + const contents = new Map(); + const schemas: Row[] = [ + { + id: 10, + space_id: 2, + source_local_id: "evidence-type", + name: "Evidence", + is_schema: true, + is_relation: false, + literal_content: {}, + }, + { + id: 11, + space_id: 2, + source_local_id: "source-type", + name: "Source", + is_schema: true, + is_relation: false, + literal_content: {}, + }, + ]; + const concepts: Row[] = [ + ...schemas, + { + id: 20, + space_id: 2, + source_local_id: "evidence", + schema_id: 10, + core_title: "Evidence title", + sourceDocument: 21, + is_schema: false, + is_relation: false, + }, + { + id: 21, + space_id: 2, + source_local_id: "source", + schema_id: 11, + core_title: "Source title", + sourceDocument: null, + is_schema: false, + is_relation: false, + }, + ]; + const contentRows: Row[] = ["evidence", "source"].flatMap((id) => [ + { + space_id: 2, + source_local_id: id, + variant: "direct", + text: `${id} incoming title`, + metadata: {}, + author_id: 1, + created: "2026-09-01T00:00:00", + last_modified: "2026-09-02T00:00:00", + }, + { + space_id: 2, + source_local_id: id, + variant: "full", + text: `${id} body`, + metadata: {}, + author_id: 1, + created: "2026-09-01T00:00:00", + last_modified: "2026-09-02T00:00:00", + }, + ]); + const spaces: Row[] = [ + { id: 1, url: "obsidian:local-vault", name: "Local" }, + { id: 2, url: REMOTE_URI, name: "Research" }, + ]; + const requests: { + table: string; + select: string; + filters: [string, unknown][]; + }[] = []; + let sourceQueryError = false; + const from = vi.fn((table: string) => { + const request = { table, select: "", filters: [] as [string, unknown][] }; + requests.push(request); + const rows = (): Row[] => { + const tableRows = + table === "my_concepts" + ? concepts + : table === "my_contents" || table === "Content" + ? contentRows + : table === "my_spaces" || table === "Space" + ? spaces + : []; + return tableRows.filter((row) => + request.filters.every(([key, value]) => + Array.isArray(value) ? value.includes(row[key]) : row[key] === value, + ), + ); + }; + const response = (single: boolean) => ({ + data: single ? (rows()[0] ?? null) : rows(), + error: + sourceQueryError && + table === "my_concepts" && + request.select === "id, source_local_id, space_id" + ? { message: "Source lookup failed" } + : null, + }); + const query = { + select: (columns: string) => { + request.select = columns; + return query; + }, + eq: (key: string, value: unknown) => { + request.filters.push([key, value]); + return query; + }, + in: (key: string, values: unknown[]) => { + request.filters.push([key, values]); + return query; + }, + maybeSingle: () => Promise.resolve(response(true)), + then: (resolve: (result: ReturnType) => unknown) => + Promise.resolve(response(false)).then(resolve), + }; + return query; + }); + const create = vi.fn((path: string, content: string) => { + if (files.has(path)) + return Promise.reject(new Error(`File already exists: ${path}`)); + const file = new TFile(); + file.path = path; + files.set(path, file); + contents.set(path, content); + return Promise.resolve(file); + }); + const renameFile = vi.fn((file: TFile, newPath: string) => { + const content = contents.get(file.path)!; + files.delete(file.path); + contents.delete(file.path); + file.path = newPath; + files.set(newPath, file); + contents.set(newPath, content); + return Promise.resolve(); + }); + const getFileCache = vi.fn((file: TFile) => ({ + frontmatter: matter(contents.get(file.path) ?? "").data, + })); + const saveSettings = vi.fn(); + const plugin = { + app: { + plugins: { plugins: {} }, + vault: { + create, + getFileByPath: (path: string) => files.get(path) ?? null, + getAbstractFileByPath: (path: string) => files.get(path) ?? null, + getMarkdownFiles: () => + [...files.values()].filter((file) => file.extension === "md"), + read: (file: TFile) => Promise.resolve(contents.get(file.path)!), + modify: (file: TFile, content: string) => { + contents.set(file.path, content); + return Promise.resolve(); + }, + process: (file: TFile, callback: (content: string) => string) => { + contents.set(file.path, callback(contents.get(file.path)!)); + return Promise.resolve(); + }, + createFolder: vi.fn(), + adapter: { exists: (path: string) => Promise.resolve(files.has(path)) }, + }, + metadataCache: { getFileCache, getFirstLinkpathDest: () => null }, + fileManager: { + renameFile, + processFrontMatter: ( + file: TFile, + callback: (frontmatter: Row) => void, + ) => { + const parsed = matter(contents.get(file.path)!); + callback(parsed.data); + contents.set( + file.path, + matter.stringify(parsed.content, parsed.data), + ); + return Promise.resolve(); + }, + }, + }, + settings: { + nodeTypes: [ + { + id: "evidence-type", + name: "Evidence", + format: "EVD - {content}", + created: 0, + modified: 0, + }, + { + id: "source-type", + name: "Source", + format: "SRC - {content}", + created: 0, + modified: 0, + }, + ], + relationTypes: [ + { + id: "based-on", + label: "Based on", + complement: "Source of", + color: "black", + created: 0, + modified: 0, + }, + ], + discourseRelations: [ + { + id: "evidence-source", + sourceId: "evidence-type", + destinationId: "source-type", + relationshipTypeId: "based-on", + created: 0, + modified: 0, + }, + ], + }, + saveSettings, + } as unknown as DiscourseGraphPlugin; + vi.mocked(getLoggedInClient).mockResolvedValue({ + from, + } as unknown as DGSupabaseClient); + vi.mocked(getSupabaseContext).mockResolvedValue({ + spaceId: 1, + platform: "Obsidian", + userId: 1, + spacePassword: "test", + }); + const pull = () => + importSelectedNodes({ plugin, selectedNodes: [selectedNode] }); + const seedSource = async (local = false): Promise => + create( + "My existing source.md", + matter.stringify("Original source body", { + nodeInstanceId: "source", + nodeTypeId: "source-type", + ...(local ? {} : { importedFromRid: sourceRid }), + }), + ); + return { + plugin, + saveSettings, + concepts, + contentRows, + requests, + files, + contents, + create, + renameFile, + getFileCache, + pull, + seedSource, + failSourceQuery: () => { + sourceQueryError = true; + }, + }; +}; diff --git a/apps/obsidian/src/utils/__tests__/mocks/importNodes.ts b/apps/obsidian/src/utils/__tests__/mocks/importNodes.ts new file mode 100644 index 000000000..254ba4048 --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/mocks/importNodes.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest"; + +vi.mock("../../supabaseContext", () => ({ + getLoggedInClient: vi.fn(), + getSupabaseContext: vi.fn(), + getVaultId: () => "local-vault", + getLocalSpaceUri: () => "obsidian:local-vault", +})); +vi.mock("../../publishNode", () => ({ + publishNewRelation: vi.fn().mockResolvedValue(false), +})); +vi.mock("../../templates", () => ({ createTemplateFile: vi.fn() })); +vi.mock("../../importFolderMetadata", () => ({ + resolveFolderForSpaceUri: vi.fn().mockResolvedValue("import/Research"), +})); +vi.mock("../../importRelations", () => ({ + importRelationsForImportedNodes: vi.fn().mockResolvedValue({ imported: 0 }), +})); diff --git a/apps/obsidian/src/utils/__tests__/sourceRoundTrip.test.mjs b/apps/obsidian/src/utils/__tests__/sourceRoundTrip.test.mjs new file mode 100644 index 000000000..078ce4ff8 --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/sourceRoundTrip.test.mjs @@ -0,0 +1,500 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import matter from "gray-matter"; +import { Notice } from "obsidian"; +import { crossAppNodeToDbConcept } from "@repo/database/lib/crossAppConverters"; +import { buildSharedNodes } from "@repo/database/lib/sharedNodes"; +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import { collectDiscourseNodesFromVault } from "../getDiscourseNodes"; +import { indexSourceSlotValues } from "../sourceSlot"; +import { discourseNodeInstanceToLocalConcept } from "../conceptConversion"; +import { loadRelations } from "../relationsStore"; +import { + createHarness, + REMOTE_URI, + evidenceRid, + sourceRid, +} from "./importNodesHarness"; +import { nodeUidsWithTypeToCrossApp } from "../../../../roam/src/utils/roamToCrossAppConverters"; +import { materializeSharedNode } from "../../../../roam/src/utils/materializeSharedNode"; + +// Both adapters run here. Only platform I/O and the SQL storage boundary are doubled. +// MJS keeps the two apps' distinct TypeScript/React configurations independent. +const io = vi.hoisted(() => ({ + pages: new Map(), + identities: new Map(), + schemas: [], +})); +vi.mock("../../../../roam/src/utils/getDiscourseNodes", () => ({ + default: () => io.schemas, +})); +vi.mock("../../../../roam/src/utils/pageToMarkdown", () => ({ + toMarkdown: () => "Research body", +})); +vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({ + default: () => ({ children: [{ text: "Research body", children: [] }] }), +})); +vi.mock("roamjs-components/queries/getPageViewType", () => ({ + default: () => "bullet", +})); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: (title) => + [...io.pages].find(([, page]) => page.title === title)?.[0] ?? "", +})); +vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ + default: (uid) => io.pages.get(uid)?.title ?? "", +})); +vi.mock("roamjs-components/queries/getShallowTreeByParentUid", () => ({ + default: () => [], +})); +vi.mock("roamjs-components/writes/deleteBlock", () => ({ default: vi.fn() })); +vi.mock("../../../../roam/src/utils/importedSourceIdentity", () => ({ + readImportedSourceIdentity: (uid) => io.identities.get(uid), + findImportedNodeUidBySourceRid: async (rid) => + [...io.identities].find( + ([, identity]) => identity.sourceNodeRid === rid, + )?.[0] ?? null, + writeImportedSourceIdentity: async ({ pageUid, ...identity }) => { + io.identities.set(pageUid, identity); + }, +})); + +const CREATED = "2026-09-01T00:00:00"; +const MODIFIED = "2026-09-02T00:00:00"; +const CORE_TITLE = "Evidence title"; +const SOURCE_TITLE = "@Source title"; +const ROAM_TITLE = `[[EVD]] - ${CORE_TITLE} - [[${SOURCE_TITLE}]]`; +const LOCAL_URI = "obsidian:local-vault"; +const OBSIDIAN_RID = spaceUriAndLocalIdToRid(LOCAL_URI, "evidence", "note"); +const SOURCE_FORMAT = { format: "@{content}" }; +const EVIDENCE_FORMAT = { format: "[[EVD]] - {content} - {Source}" }; +const context = { + platform: "Obsidian", + spaceId: 1, + userId: 1, + spacePassword: "test", +}; +const sourceConcept = { id: 21, space_id: 1, source_local_id: "source" }; + +const installRoam = () => { + const createPage = vi.fn(async ({ page, "markdown-string": body = "" }) => { + io.pages.set(page.uid, { title: page.title, body }); + }); + const updatePage = vi.fn(async ({ page }) => { + io.pages.get(page.uid).title = page.title; + }); + const pullMany = vi.fn(async (pattern, ids) => + pattern.includes(":user/uid") + ? [{ ":db/id": 1, ":user/uid": "author" }] + : ids.map(([, uid]) => ({ + ":block/uid": uid, + ":node/title": io.pages.get(uid).title, + ":create/user": { ":db/id": 1 }, + ":create/time": Date.parse(CREATED + "Z"), + ":page/edit-time": Date.parse(MODIFIED + "Z"), + })), + ); + globalThis.window = { + roamAlphaAPI: { + graph: { name: "target-graph" }, + util: { generateUID: () => `page-${io.pages.size + 1}` }, + q: (_query, uid) => (io.pages.has(uid) ? [[uid]] : []), + updatePage, + data: { + async: { pull_many: pullMany }, + page: { + fromMarkdown: createPage, + create: createPage, + delete: async ({ page }) => io.pages.delete(page.uid), + }, + block: { fromMarkdown: vi.fn() }, + }, + }, + }; + io.schemas = [ + { type: "evidence-type", text: "Evidence", ...EVIDENCE_FORMAT }, + { type: "source-type", text: "Source", ...SOURCE_FORMAT }, + ]; + return { createPage, updatePage }; +}; + +// Mirrors the storage boundary's local-reference resolution using fixed concept IDs. +// No adapter output is hand-written: the actual push result supplies the reference. +const storedSource = (input, references) => { + const value = input.local_reference_content?.sourceDocument; + if (value === undefined) return undefined; + const reference = references.find( + (row) => + row.source_local_id === value || + spaceUriAndLocalIdToRid(row.spaceUri, row.source_local_id, "note") === + value, + ); + if (!reference) throw new Error(`Unresolved source fixture: ${value}`); + return reference; +}; + +const sharedFromObsidian = ({ + input, + references = [sourceConcept], + visible = true, + localId = "evidence", + title = "EVD - Evidence title", + coreTitle = CORE_TITLE, +}) => { + const stored = storedSource( + input, + references.map((row) => ({ + ...row, + spaceUri: row.space_id === 1 ? LOCAL_URI : REMOTE_URI, + })), + ); + const [shared] = buildSharedNodes({ + spaces: [ + { id: 1, name: "Local vault", platform: "Obsidian", url: LOCAL_URI }, + { id: 2, name: "Research", platform: "Roam", url: REMOTE_URI }, + ], + nodes: [ + { + is_schema: false, + schema_id: 10, + space_id: 1, + source_local_id: localId, + last_modified: MODIFIED, + core_title: coreTitle, + reference_content: stored ? { sourceDocument: stored.id } : {}, + concepts_of_relation: stored && visible ? [stored] : [], + }, + ], + directContents: [ + { + space_id: 1, + source_local_id: localId, + text: title, + variant: "direct", + author_id: 1, + metadata: {}, + created: CREATED, + last_modified: MODIFIED, + }, + ], + fullContentSummaries: [], + }); + return shared; +}; + +const contentClient = { + from: () => { + const query = { + select: () => query, + eq: () => query, + maybeSingle: async () => ({ + data: { text: "Research body", content_type: "text/obsidian+markdown" }, + error: null, + }), + }; + return query; + }, +}; + +const importSourceIntoRoam = () => + materializeSharedNode({ + client: contentClient, + sharedNode: sharedFromObsidian({ + input: {}, + localId: "source", + title: "SRC - Source title", + coreTitle: "Source title", + }), + nodeType: SOURCE_FORMAT, + }); +const pullIntoRoam = (sharedNode, force = false) => + materializeSharedNode({ + client: contentClient, + sharedNode, + nodeType: EVIDENCE_FORMAT, + force, + }); +const obsidianPush = async (h, relations) => { + const nodes = await collectDiscourseNodesFromVault(h.plugin, true); + const nodeTypesById = Object.fromEntries( + h.plugin.settings.nodeTypes.map((type) => [type.id, type]), + ); + const values = indexSourceSlotValues({ + relations, + nodes, + localSpaceUri: LOCAL_URI, + nodeTypesById, + }); + const nodeData = nodes.find((node) => node.nodeInstanceId === "evidence"); + const input = discourseNodeInstanceToLocalConcept({ + context, + nodeData, + nodeTypesById, + sourceSlotByNodeId: values, + }); + return { input, values }; +}; +const localNodes = async (h) => { + await h.create( + "EVD - Evidence title.md", + matter.stringify("Research body", { + nodeInstanceId: "evidence", + nodeTypeId: "evidence-type", + }), + ); + await h.create( + "SRC - Source title.md", + matter.stringify("Source body", { + nodeInstanceId: "source", + nodeTypeId: "source-type", + }), + ); +}; +const sourceRelation = { + id: "earliest", + type: "based-on", + source: "evidence", + destination: "source", + created: 1, +}; + +beforeEach(() => { + vi.clearAllMocks(); + io.pages.clear(); + io.identities.clear(); + installRoam(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +describe("Roam push → database → Obsidian pull", () => { + const push = async ({ withSource = true, importedSource = false } = {}) => { + const title = withSource ? ROAM_TITLE : "[[EVD]] - Evidence title"; + io.schemas[0].format = withSource + ? EVIDENCE_FORMAT.format + : "[[EVD]] - {content}"; + io.pages.set("evidence", { title }); + if (withSource) io.pages.set("source", { title: SOURCE_TITLE }); + if (importedSource) + io.identities.set("source", { + sourceNodeRid: sourceRid, + sourceModifiedAt: MODIFIED + "Z", + }); + const [node] = await nodeUidsWithTypeToCrossApp([ + { uid: "evidence", type: "evidence-type" }, + ]); + return crossAppNodeToDbConcept(node); + }; + const publishIntoHarness = (h, input) => { + const row = h.concepts.find((row) => row.source_local_id === "evidence"); + const stored = storedSource(input, [ + { id: 21, space_id: 2, source_local_id: "source", spaceUri: REMOTE_URI }, + ]); + row.core_title = input.literal_content.core_title; + row.sourceDocument = stored?.id ?? null; + for (const content of input.contents_inline) { + const row = h.contentRows.find( + (row) => + row.source_local_id === "evidence" && row.variant === content.variant, + ); + row.text = content.text; + } + }; + + it.each([false, true])( + "preserves the referenced Source (producer Source imported=%s)", + async (importedSource) => { + const h = createHarness(); + const input = await push({ importedSource }); + expect(input.local_reference_content).toEqual({ + sourceDocument: importedSource ? sourceRid : "source", + }); + publishIntoHarness(h, input); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + const relations = Object.values( + (await loadRelations(h.plugin)).relations, + ); + expect(relations).toEqual([ + expect.objectContaining({ + source: evidenceRid, + destination: sourceRid, + type: "based-on", + }), + ]); + expect([...h.files.keys()]).toContain( + "import/Research/SRC - Source title.md", + ); + const republished = await obsidianPush(h, relations); + expect(republished.input.local_reference_content).toEqual({ + sourceDocument: sourceRid, + }); + }, + ); + + it("completes without a source or a placeholder", async () => { + const h = createHarness(); + const input = await push({ withSource: false }); + expect(input.local_reference_content).toBeUndefined(); + publishIntoHarness(h, input); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect([...h.files.keys()]).toEqual([ + "import/Research/EVD - Evidence title.md", + ]); + expect((await loadRelations(h.plugin)).relations).toEqual({}); + expect(Notice).not.toHaveBeenCalled(); + }); + + it.each(["not-shared", "no-content"])( + "warns without blocking the Evidence when the Source is %s", + async (reason) => { + const h = createHarness(); + publishIntoHarness(h, await push()); + if (reason === "not-shared") + h.concepts.splice( + h.concepts.findIndex((row) => row.id === 21), + 1, + ); + else h.contentRows.splice(2); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect([...h.files.keys()]).toEqual([ + "import/Research/EVD - Evidence title.md", + ]); + expect((await loadRelations(h.plugin)).relations).toEqual({}); + expect(Notice).toHaveBeenCalledWith( + expect.stringContaining("Source is unavailable"), + ); + }, + ); + + it("keeps stable identities, relations and titles across repeated push/pull", async () => { + const h = createHarness(); + const first = await push(); + publishIntoHarness(h, first); + await h.pull(); + const originalRelations = await loadRelations(h.plugin); + const paths = [...h.files.keys()]; + for (let n = 0; n < 3; n++) { + const input = await push(); + expect(input).toEqual(first); + publishIntoHarness(h, input); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + } + expect(await loadRelations(h.plugin)).toEqual(originalRelations); + expect([...h.files.keys()]).toEqual(paths); + expect(h.renameFile).not.toHaveBeenCalled(); + }); +}); + +describe("Obsidian push → database → Roam pull", () => { + it("reconstructs the referenced node from an Obsidian Source relation", async () => { + const h = createHarness(); + await localNodes(h); + const { input } = await obsidianPush(h, [sourceRelation]); + expect(input.local_reference_content).toEqual({ sourceDocument: "source" }); + const shared = sharedFromObsidian({ input }); + expect(shared.slots).toEqual({ sourceDocument: "source" }); + await importSourceIntoRoam(); + const result = await pullIntoRoam(shared); + expect(result).toMatchObject({ + success: true, + action: "created", + sourceNodeRid: OBSIDIAN_RID, + }); + expect(io.pages.get(result.pageUid).title).toBe(ROAM_TITLE); + }); + + it("selects the earliest Source relation by created date, regardless of array order", async () => { + const h = createHarness(); + await localNodes(h); + await h.create( + "SRC - Newer source.md", + matter.stringify("body", { + nodeInstanceId: "newer-source", + nodeTypeId: "source-type", + }), + ); + const relations = [ + { + ...sourceRelation, + id: "later", + destination: "newer-source", + created: 20, + }, + sourceRelation, + ]; + const { input } = await obsidianPush(h, relations); + expect(input.local_reference_content).toEqual({ sourceDocument: "source" }); + await importSourceIntoRoam(); + const result = await pullIntoRoam(sharedFromObsidian({ input })); + expect(io.pages.get(result.pageUid).title).toBe(ROAM_TITLE); + }); + + it.each(["absent", "unavailable", "not-imported"])( + "keeps the incoming title and warns for a %s Source", + async (state) => { + const h = createHarness(); + await localNodes(h); + const { input } = await obsidianPush( + h, + state === "absent" ? [] : [sourceRelation], + ); + const shared = sharedFromObsidian({ + input, + visible: state !== "unavailable", + }); + const result = await pullIntoRoam(shared); + expect(result).toMatchObject({ + success: true, + action: "created", + warning: expect.stringContaining("kept as published"), + }); + expect(io.pages.get(result.pageUid).title).toBe(shared.title); + expect(io.pages.size).toBe(1); + if (state === "absent") + expect(input.local_reference_content).toBeUndefined(); + }, + ); + + it("repeated push, pull and forced refresh do not create pages or rename unchanged titles", async () => { + const h = createHarness(); + await localNodes(h); + const { createPage, updatePage } = installRoam(); + const { input } = await obsidianPush(h, [sourceRelation]); + await importSourceIntoRoam(); + const first = await pullIntoRoam(sharedFromObsidian({ input })); + for (let n = 0; n < 3; n++) { + const repeated = await obsidianPush(h, [sourceRelation]); + expect(repeated.input).toEqual(input); + const shared = sharedFromObsidian({ input: repeated.input }); + expect(await pullIntoRoam(shared)).toMatchObject({ + action: "skipped", + pageUid: first.pageUid, + }); + expect(await pullIntoRoam(shared, true)).toMatchObject({ + action: "updated", + pageUid: first.pageUid, + }); + } + expect(io.pages.size).toBe(2); + expect(createPage).toHaveBeenCalledTimes(2); + expect(updatePage).not.toHaveBeenCalled(); + }); + + it("fills the missing reference after the Source is imported, then keeps the title stable", async () => { + const h = createHarness(); + await localNodes(h); + const { updatePage } = installRoam(); + const { input } = await obsidianPush(h, [sourceRelation]); + const shared = sharedFromObsidian({ input }); + const first = await pullIntoRoam(shared); + expect(first.warning).toBeDefined(); + await importSourceIntoRoam(); + expect(await pullIntoRoam(shared, true)).toMatchObject({ + success: true, + action: "updated", + pageUid: first.pageUid, + }); + expect(io.pages.get(first.pageUid).title).toBe(ROAM_TITLE); + await pullIntoRoam(shared, true); + expect(updatePage).toHaveBeenCalledTimes(1); + expect(io.pages.size).toBe(2); + }); +}); diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts index acc9fa4ee..996866e63 100644 --- a/apps/obsidian/vitest.config.mts +++ b/apps/obsidian/vitest.config.mts @@ -7,11 +7,16 @@ const dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ test: { environment: "node", - include: ["src/utils/__tests__/**/*.test.ts"], + setupFiles: ["src/utils/__tests__/mocks/importNodes.ts"], + include: ["src/utils/__tests__/**/*.test.{ts,mjs}"], }, resolve: { alias: { "~": path.resolve(dirname, "src"), + "roamjs-components": path.resolve( + dirname, + "../roam/node_modules/roamjs-components", + ), obsidian: path.resolve(dirname, "src/utils/__tests__/mocks/obsidian.ts"), }, }, From 04f3a666116fc01898e6fe98905431ca35381e2e Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 16:30:32 +0530 Subject: [PATCH 2/3] ENG-2143 Invalidate round-trip tests when adapter dependencies change --- turbo.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/turbo.json b/turbo.json index e25f91045..c6dacafb0 100644 --- a/turbo.json +++ b/turbo.json @@ -78,6 +78,14 @@ "test:unit": { "outputs": [] }, + "@discourse-graphs/obsidian#test:unit": { + "dependsOn": [ + "roam#test:unit", + "@repo/database#test:unit", + "@repo/content-model#test:unit" + ], + "outputs": [] + }, "//#test:unit": { "inputs": [ "package.json", From a1363dc7732f09ab3952b64646042e8f3106743d Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 16:33:03 +0530 Subject: [PATCH 3/3] ENG-2143 Retain frontmatter import for inherited regression tests --- apps/obsidian/src/utils/__tests__/importNodes.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/obsidian/src/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts index 4e085a234..1866046b7 100644 --- a/apps/obsidian/src/utils/__tests__/importNodes.test.ts +++ b/apps/obsidian/src/utils/__tests__/importNodes.test.ts @@ -5,6 +5,7 @@ import { selectedNode, } from "./importNodesHarness"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import matter from "gray-matter"; import { Notice } from "obsidian"; import type { RelationInstance } from "~/types"; import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid";