diff --git a/bun.lockb b/bun.lockb index af74b972..ced4413d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4c7ca899..0cf95521 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "jsdom": "^26.1.0", "obsidian": "1.11.4", "obsidian-dataview": "^0.5.68", - "obsidian-e2e": "0.4.0", + "obsidian-e2e": "0.6.0", "semantic-release": "^24.2.6", "svelte": "^4.2.19", "svelte-check": "^3.8.6", diff --git a/src/engine/CaptureChoiceEngine.selection.test.ts b/src/engine/CaptureChoiceEngine.selection.test.ts index 69e31658..06f9b837 100644 --- a/src/engine/CaptureChoiceEngine.selection.test.ts +++ b/src/engine/CaptureChoiceEngine.selection.test.ts @@ -23,6 +23,9 @@ vi.mock("../formatters/captureChoiceFormatter", () => ({ } setDestinationFile() {} setDestinationSourcePath() {} + async withTemplatePropertyCollection(work: () => Promise) { + return await work(); + } async formatContentOnly(content: string) { return content; } diff --git a/src/engine/CaptureChoiceEngine.template-property-types.test.ts b/src/engine/CaptureChoiceEngine.template-property-types.test.ts index 39d5de65..02b9800d 100644 --- a/src/engine/CaptureChoiceEngine.template-property-types.test.ts +++ b/src/engine/CaptureChoiceEngine.template-property-types.test.ts @@ -147,7 +147,7 @@ describe("CaptureChoiceEngine template property types", () => { }; }); - it("post-processes capture frontmatter arrays into YAML lists", async () => { + it("writes a YAML-safe placeholder before post-processing capture frontmatter arrays", async () => { const targetPath = "Journal/Test.md"; const createdContent: Record = {}; let writtenContent = ""; @@ -270,7 +270,9 @@ describe("CaptureChoiceEngine template property types", () => { await engine.run(); - expect(writtenContent).toContain("tags: foo,bar"); + // The raw file write only needs to stay YAML-parseable; processFrontMatter + // applies the final structured array value afterward. + expect(writtenContent).toContain("tags: []"); expect(processFrontMatter).toHaveBeenCalledTimes(1); expect(appliedFrontmatter?.tags).toEqual(["foo", "bar"]); }); diff --git a/src/engine/CaptureChoiceEngine.ts b/src/engine/CaptureChoiceEngine.ts index 6b70a479..8fff3227 100644 --- a/src/engine/CaptureChoiceEngine.ts +++ b/src/engine/CaptureChoiceEngine.ts @@ -628,18 +628,22 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { this.formatter.setDestinationFile(file); // First format pass... - const formatted = await this.formatter.formatContentOnly(content); + const formatted = await this.formatter.withTemplatePropertyCollection( + () => this.formatter.formatContentOnly(content), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); const fileContent: string = await this.app.vault.read(file); // Second format pass, with the file content... User input (long running) should have been captured during first pass // So this pass is to insert the formatted capture value into the file content, depending on the user's settings const formattedFileContent: string = - await this.formatter.formatContentWithFile( - formatted, - this.choice, - fileContent, - file, + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentWithFile( + formatted, + this.choice, + fileContent, + file, + ), ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); @@ -685,7 +689,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // This mirrors the logic used when the target file already exists and prevents the timing issue // where templater would run before the {{value}} placeholder is substituted (Issue #809). const formattedCaptureContent: string = - await this.formatter.formatContentOnly(captureContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentOnly(captureContent), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); let fileContent = ""; @@ -743,12 +749,15 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine { // after the initial Templater run on newly created files. const updatedFileContent: string = await this.app.vault.read(file); // Second formatting pass: embed the already-resolved capture content into the newly created file - const newFileContent: string = await this.formatter.formatContentWithFile( - formattedCaptureContent, - this.choice, - updatedFileContent, - file, - ); + const newFileContent: string = + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatContentWithFile( + formattedCaptureContent, + this.choice, + updatedFileContent, + file, + ), + ); this.mergeCapturePropertyVars(this.formatter.getAndClearTemplatePropertyVars()); return { file, newFileContent, captureContent: formattedCaptureContent }; diff --git a/src/engine/SingleTemplateEngine.ts b/src/engine/SingleTemplateEngine.ts index c6a0e932..0f7b35b7 100644 --- a/src/engine/SingleTemplateEngine.ts +++ b/src/engine/SingleTemplateEngine.ts @@ -21,8 +21,8 @@ export class SingleTemplateEngine extends TemplateEngine { log.logError(`Template ${this.templatePath} not found.`); } - templateContent = await this.formatter.formatFileContent( - templateContent + templateContent = await this.formatter.withTemplatePropertyCollection( + () => this.formatter.formatFileContent(templateContent), ); return templateContent; diff --git a/src/engine/TemplateChoiceEngine.notice.test.ts b/src/engine/TemplateChoiceEngine.notice.test.ts index 146b940f..19111314 100644 --- a/src/engine/TemplateChoiceEngine.notice.test.ts +++ b/src/engine/TemplateChoiceEngine.notice.test.ts @@ -66,6 +66,9 @@ vi.mock("../formatters/completeFormatter", () => { async formatFileContent(...args: unknown[]) { return await formatFileContentMock(...args); } + async withTemplatePropertyCollection(work: () => Promise) { + return await work(); + } getAndClearTemplatePropertyVars() { return new Map(); } diff --git a/src/engine/TemplateEngine.ts b/src/engine/TemplateEngine.ts index accc2d4e..7453487a 100644 --- a/src/engine/TemplateEngine.ts +++ b/src/engine/TemplateEngine.ts @@ -480,7 +480,9 @@ export abstract class TemplateEngine extends QuickAddEngine { this.formatter.setTitle(fileBasename); const formattedTemplateContent: string = - await this.formatter.formatFileContent(templateContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatFileContent(templateContent), + ); // Get template variables before creating the file const templateVars = this.formatter.getAndClearTemplatePropertyVars(); @@ -537,7 +539,9 @@ export abstract class TemplateEngine extends QuickAddEngine { this.formatter.setTitle(fileBasename); const formattedTemplateContent: string = - await this.formatter.formatFileContent(templateContent); + await this.formatter.withTemplatePropertyCollection(() => + this.formatter.formatFileContent(templateContent), + ); // Get template variables before modifying the file const templateVars = this.formatter.getAndClearTemplatePropertyVars(); diff --git a/src/engine/templateEngine-title.test.ts b/src/engine/templateEngine-title.test.ts index 473a3a04..a3dfe1a1 100644 --- a/src/engine/templateEngine-title.test.ts +++ b/src/engine/templateEngine-title.test.ts @@ -12,6 +12,9 @@ vi.mock('../formatters/completeFormatter', () => { return { setTitle: vi.fn((t: string) => { title = t; }), getTitle: () => title, + withTemplatePropertyCollection: vi.fn( + async (work: () => Promise) => await work(), + ), formatFileContent: vi.fn(async (content: string) => { // Simple mock that replaces {{title}} with the stored title return content.replace(/{{title}}/gi, title); diff --git a/src/formatters/formatter-template-property-types.test.ts b/src/formatters/formatter-template-property-types.test.ts index 395098f7..742fca8e 100644 --- a/src/formatters/formatter-template-property-types.test.ts +++ b/src/formatters/formatter-template-property-types.test.ts @@ -24,8 +24,7 @@ class TemplatePropertyTypesTestFormatter extends Formatter { } protected getVariableValue(variableName: string): string { - const value = this.variables.get(variableName); - return typeof value === 'string' ? value : ''; + return (this.variables.get(variableName) as string) ?? ''; } protected suggestForValue( @@ -85,6 +84,14 @@ class TemplatePropertyTypesTestFormatter extends Formatter { public async testFormat(input: string): Promise { return await this.format(input); } + + public async testFormatWithTemplatePropertyCollection( + input: string, + ): Promise { + return await this.withTemplatePropertyCollection(() => + this.testFormat(input), + ); + } } describe('Formatter template property type inference', () => { @@ -110,28 +117,68 @@ describe('Formatter template property type inference', () => { it('collects comma-separated values as YAML arrays', async () => { (formatter as any).variables.set('tags', 'tag1, tag2, awesomeproject'); - await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\ntags: {{VALUE:tags}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.get('tags')).toEqual(['tag1', 'tag2', 'awesomeproject']); }); it('does not collect comma text for scalar properties', async () => { (formatter as any).variables.set('description', 'Hello, world'); - await formatter.testFormat('---\ndescription: {{VALUE:description}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\ndescription: {{VALUE:description}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.has('description')).toBe(false); }); it('collects bullet list values as YAML arrays', async () => { (formatter as any).variables.set('projects', '- project1\n- project2'); - await formatter.testFormat('---\nprojects: {{VALUE:projects}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\nprojects: {{VALUE:projects}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.get('projects')).toEqual(['project1', 'project2']); }); + it('preserves raw structured replacements outside template property collection', async () => { + (formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']); + const output = await formatter.testFormat('---\ntags: {{VALUE:tags}}\n---'); + const vars = formatter.getAndClearTemplatePropertyVars(); + + expect(output).toBe('---\ntags: [[John Doe]],[[Jane Doe]]\n---'); + expect(vars.size).toBe(0); + }); + + it('uses a YAML-safe placeholder for collected arrays before post-processing', async () => { + (formatter as any).variables.set('tags', ['[[John Doe]]', '[[Jane Doe]]']); + const output = await formatter.testFormatWithTemplatePropertyCollection( + '---\ntags: {{VALUE:tags}}\n---', + ); + const vars = formatter.getAndClearTemplatePropertyVars(); + + expect(output).toBe('---\ntags: []\n---'); + expect(vars.get('tags')).toEqual(['[[John Doe]]', '[[Jane Doe]]']); + }); + + it('does not apply case transforms to YAML placeholders', async () => { + (formatter as any).variables.set('done', null); + const output = + await formatter.testFormatWithTemplatePropertyCollection( + '---\ndone: {{VALUE:done|case:upper}}\n---', + ); + const vars = formatter.getAndClearTemplatePropertyVars(); + + expect(output).toBe('---\ndone: null\n---'); + expect(vars.get('done')).toBeNull(); + }); + it('ignores wiki links with commas to avoid incorrect splitting', async () => { (formatter as any).variables.set('source', '[[test, a]]'); - await formatter.testFormat('---\nsource: {{VALUE:source}}\n---'); + await formatter.testFormatWithTemplatePropertyCollection( + '---\nsource: {{VALUE:source}}\n---', + ); const vars = formatter.getAndClearTemplatePropertyVars(); expect(vars.has('source')).toBe(false); }); diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index 40049548..8686cfdb 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -27,6 +27,7 @@ import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector"; import { settingsStore } from "../settingsStore"; import { normalizeDateInput } from "../utils/dateAliases"; import { transformCase } from "../utils/caseTransform"; +import { getYamlPlaceholder } from "../utils/yamlValues"; import { parseAnonymousValueOptions, parseValueToken, @@ -58,6 +59,7 @@ export abstract class Formatter { // Tracks variables collected for YAML property post-processing private readonly propertyCollector: TemplatePropertyCollector; + private templatePropertyCollectionDepth = 0; protected constructor(protected readonly app?: App) { this.propertyCollector = new TemplatePropertyCollector(app); @@ -302,6 +304,23 @@ export abstract class Formatter { return this.propertyCollector.drain(); } + /** + * Runs a formatting operation in a scope where structured YAML values should + * be collected and replaced with temporary placeholders for later + * `processFrontMatter()` post-processing. + */ + public async withTemplatePropertyCollection( + work: () => Promise, + ): Promise { + this.templatePropertyCollectionDepth += 1; + + try { + return await work(); + } finally { + this.templatePropertyCollectionDepth -= 1; + } + } + protected abstract getCurrentFileLink(): string | null; protected abstract getCurrentFileName(): string | null; @@ -386,18 +405,22 @@ export abstract class Formatter { : rawValue; // Offer this variable to the property collector for YAML post-processing - this.propertyCollector.maybeCollect({ + const structuredYamlValue = this.propertyCollector.maybeCollect({ input: output, matchStart: match.index, matchEnd: match.index + match[0].length, rawValue: rawValueForCollector, fallbackKey: variableName, - featureEnabled: propertyTypesEnabled, + featureEnabled: + propertyTypesEnabled && + this.templatePropertyCollectionDepth > 0, }); - // Always use string replacement initially - const rawReplacement = this.getVariableValue(effectiveKey); - const replacement = transformCase(rawReplacement, caseStyle); + // Keep the interim frontmatter YAML-parseable until post-processing + // writes the real structured value back through Obsidian. + const placeholder = getYamlPlaceholder(structuredYamlValue); + const replacement = placeholder ?? + transformCase(this.getVariableValue(effectiveKey), caseStyle); // Replace in output and adjust regex position output = output.slice(0, match.index) + replacement + output.slice(match.index + match[0].length); diff --git a/src/utils/TemplatePropertyCollector.ts b/src/utils/TemplatePropertyCollector.ts index 4f9394f1..36874d6c 100644 --- a/src/utils/TemplatePropertyCollector.ts +++ b/src/utils/TemplatePropertyCollector.ts @@ -4,6 +4,7 @@ import { parseStructuredPropertyValueFromString, type ParseOptions, } from "./templatePropertyStringParser"; +import { isStructuredYamlValue } from "./yamlValues"; const PATH_SEPARATOR = "\u0000"; @@ -26,13 +27,13 @@ export class TemplatePropertyCollector { * Collects a variable for YAML post-processing when it is a complete value for a YAML key * and the raw value is a structured type (object/array/number/boolean/null). */ - public maybeCollect(args: CollectArgs): void { + public maybeCollect(args: CollectArgs): unknown | undefined { const { input, matchStart, matchEnd, rawValue, fallbackKey, featureEnabled } = args; - if (!featureEnabled) return; + if (!featureEnabled) return undefined; const yamlRange = findYamlFrontMatterRange(input); const context = getYamlContextForMatch(input, matchStart, matchEnd, yamlRange); - if (!context.isInYaml) return; + if (!context.isInYaml) return undefined; const lineContent = input.slice(context.lineStart, context.lineEnd); const trimmedLine = lineContent.trim(); @@ -49,7 +50,7 @@ export class TemplatePropertyCollector { propertyPath = this.findListParentPath(input, context.lineStart, context.baseIndent ?? ""); } - if (!propertyPath || propertyPath.length === 0) return; + if (!propertyPath || propertyPath.length === 0) return undefined; const effectiveKey = propertyPath[propertyPath.length - 1]; let structuredValue = rawValue; @@ -61,17 +62,11 @@ export class TemplatePropertyCollector { } } - const isStructured = - typeof structuredValue !== "string" && - (Array.isArray(structuredValue) || - (typeof structuredValue === "object" && structuredValue !== null) || - typeof structuredValue === "number" || - typeof structuredValue === "boolean" || - structuredValue === null); - if (!isStructured) return; + if (!isStructuredYamlValue(structuredValue)) return undefined; const mapKey = propertyPath.join(PATH_SEPARATOR); this.map.set(mapKey, structuredValue); + return structuredValue; } /** Returns a copy and clears the collector. */ diff --git a/src/utils/yamlValues.test.ts b/src/utils/yamlValues.test.ts index 0ccee9f8..73102833 100644 --- a/src/utils/yamlValues.test.ts +++ b/src/utils/yamlValues.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { coerceYamlValue } from "./yamlValues"; +import { + coerceYamlValue, + getYamlPlaceholder, + isStructuredYamlValue, +} from "./yamlValues"; describe("coerceYamlValue", () => { it("converts @date:ISO to Date", () => { @@ -23,3 +27,33 @@ describe("coerceYamlValue", () => { expect(coerceYamlValue(arr)).toBe(arr); }); }); + +describe("isStructuredYamlValue", () => { + it("accepts structured YAML property values", () => { + expect(isStructuredYamlValue(["a"])).toBe(true); + expect(isStructuredYamlValue({ a: 1 })).toBe(true); + expect(isStructuredYamlValue(42)).toBe(true); + expect(isStructuredYamlValue(false)).toBe(true); + expect(isStructuredYamlValue(null)).toBe(true); + }); + + it("rejects plain string placeholders", () => { + expect(isStructuredYamlValue("hello")).toBe(false); + expect(isStructuredYamlValue(undefined)).toBe(false); + }); +}); + +describe("getYamlPlaceholder", () => { + it("returns YAML-safe placeholders for structured values", () => { + expect(getYamlPlaceholder(["a"])).toBe("[]"); + expect(getYamlPlaceholder({ a: 1 })).toBe("{}"); + expect(getYamlPlaceholder(42)).toBe("42"); + expect(getYamlPlaceholder(true)).toBe("true"); + expect(getYamlPlaceholder(null)).toBe("null"); + }); + + it("returns undefined for non-structured values", () => { + expect(getYamlPlaceholder("hello")).toBeUndefined(); + expect(getYamlPlaceholder(undefined)).toBeUndefined(); + }); +}); diff --git a/src/utils/yamlValues.ts b/src/utils/yamlValues.ts index 5a6db573..d0e12931 100644 --- a/src/utils/yamlValues.ts +++ b/src/utils/yamlValues.ts @@ -37,3 +37,29 @@ export function coerceYamlValue(v: unknown): unknown { // Return original value for non-@date: strings and invalid dates return v; } + +/** + * Returns whether a value should be written back through Obsidian's YAML + * processor as a structured property type instead of plain string text. + */ +export function isStructuredYamlValue(v: unknown): boolean { + return typeof v !== "string" && ( + Array.isArray(v) || + (typeof v === "object" && v !== null) || + typeof v === "number" || + typeof v === "boolean" || + v === null + ); +} + +/** + * Produces a YAML-parseable placeholder for structured values so frontmatter + * stays valid until processFrontMatter rewrites the final value. + */ +export function getYamlPlaceholder(v: unknown): string | undefined { + if (!isStructuredYamlValue(v)) return undefined; + if (Array.isArray(v)) return "[]"; + if (v === null) return "null"; + if (typeof v === "object") return "{}"; + return String(v); +} diff --git a/tests/e2e/template-property-links.test.ts b/tests/e2e/template-property-links.test.ts new file mode 100644 index 00000000..f57fe5d9 --- /dev/null +++ b/tests/e2e/template-property-links.test.ts @@ -0,0 +1,229 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + acquireVaultRunLock, + captureFailureArtifacts, + clearVaultRunLockMarker, + createObsidianClient, + createSandboxApi, +} from "obsidian-e2e"; +import type { + ObsidianClient, + PluginHandle, + SandboxApi, + VaultRunLock, +} from "obsidian-e2e"; + +const VAULT = "dev"; +const PLUGIN_ID = "quickadd"; +const WAIT_OPTS = { timeoutMs: 10_000, intervalMs: 200 }; + +let obsidian: ObsidianClient; +let sandbox: SandboxApi; +let qa: PluginHandle; +let lock: VaultRunLock | undefined; + +type QuickAddData = { + choices: Record[]; + migrations: Record; + enableTemplatePropertyTypes?: boolean; +}; + +function templateChoice(id: string, templatePath: string, format: string) { + return { + id, + name: id, + type: "Template", + command: false, + templatePath, + fileNameFormat: { enabled: true, format }, + folder: { + enabled: false, + folders: [], + chooseWhenCreatingNote: false, + createInSameFolderAsActiveFile: false, + chooseFromSubfolders: false, + }, + appendLink: false, + openFile: false, + fileOpening: { + location: "tab", + direction: "vertical", + mode: "source", + focus: false, + }, + }; +} + +function clearTestChoices(data: QuickAddData) { + data.choices = data.choices.filter( + (choice) => !String(choice.id ?? "").startsWith("__qa-test-1140-"), + ); +} + +async function seedTemplate(path: string, content: string) { + await sandbox.write(path, content, { + waitForContent: true, + waitOptions: WAIT_OPTS, + }); +} + +async function runChoice(name: string, vars: Record) { + await obsidian.exec("quickadd:run", { + choice: name, + vars: JSON.stringify(vars), + }); +} + +async function runChoiceAndWaitForFile( + name: string, + vars: Record, + file: string, +) { + await runChoice(name, vars); + await sandbox.waitForExists(file, WAIT_OPTS); +} + +async function waitForFrontmatter( + file: string, + predicate: (frontmatter: { authors: string[] }) => boolean, +) { + return await obsidian.metadata.waitForFrontmatter<{ + authors: string[]; + }>( + sandbox.path(file), + predicate, + WAIT_OPTS, + ); +} + +async function runTeardownStep( + label: string, + step: () => Promise | unknown, + errors: unknown[], +) { + try { + await step(); + } catch (error) { + errors.push(error); + console.warn( + `template-property-links teardown failed during ${label}`, + error, + ); + } +} + +beforeAll(async () => { + obsidian = createObsidianClient({ vault: VAULT }); + await obsidian.verify(); + + lock = await acquireVaultRunLock({ + vaultName: VAULT, + vaultPath: await obsidian.vaultPath(), + }); + await lock.publishMarker(obsidian); + + qa = obsidian.plugin(PLUGIN_ID); + sandbox = await createSandboxApi({ + obsidian, + sandboxRoot: "__obsidian_e2e__", + testName: "template-property-links", + }); +}, 30_000); + +afterAll(async () => { + const errors: unknown[] = []; + + await runTeardownStep("restoreData", () => qa?.restoreData?.(), errors); + await runTeardownStep("reload", () => qa?.reload?.(), errors); + await runTeardownStep("sandbox cleanup", () => sandbox?.cleanup?.(), errors); + await runTeardownStep( + "clear vault run lock marker", + () => (obsidian ? clearVaultRunLockMarker(obsidian) : undefined), + errors, + ); + await runTeardownStep("release vault lock", () => lock?.release(), errors); + + if (errors.length > 0) { + throw errors[0]; + } +}, 15_000); + +beforeEach((ctx) => { + ctx.onTestFailed(async () => { + await captureFailureArtifacts( + { id: ctx.task.id, name: ctx.task.name }, + obsidian, + { plugin: qa, captureOnFailure: true }, + ); + }); +}); + +describe("issue 1140: list properties with links", () => { + beforeAll(async () => { + const root = sandbox.root; + const templatePath = sandbox.path("issue-1140-template.md"); + + await seedTemplate( + "issue-1140-template.md", + [ + "---", + "authors: {{VALUE:authors}}", + "---", + "", + ].join("\n"), + ); + + await qa.data().patch((data) => { + clearTestChoices(data); + data.enableTemplatePropertyTypes = true; + data.choices.push( + templateChoice( + "__qa-test-1140-single-link", + templatePath, + `${root}/qa-1140-single-link`, + ), + templateChoice( + "__qa-test-1140-multi-link", + templatePath, + `${root}/qa-1140-multi-link`, + ), + ); + }); + + await qa.reload({ waitUntilReady: true }); + }, 15_000); + + it("formats a single wikilink list item as a YAML list", async () => { + await runChoiceAndWaitForFile( + "__qa-test-1140-single-link", + { authors: ["[[John Doe]]"] }, + "qa-1140-single-link.md", + ); + const frontmatter = await waitForFrontmatter( + "qa-1140-single-link.md", + (value) => + Array.isArray(value.authors) && value.authors.length === 1, + ); + + expect(frontmatter).toMatchObject({ + authors: ["[[John Doe]]"], + }); + }); + + it("formats multiple wikilinks as separate YAML list items", async () => { + await runChoiceAndWaitForFile( + "__qa-test-1140-multi-link", + { authors: ["[[John Doe]]", "[[Jane Doe]]"] }, + "qa-1140-multi-link.md", + ); + const frontmatter = await waitForFrontmatter( + "qa-1140-multi-link.md", + (value) => + Array.isArray(value.authors) && value.authors.length === 2, + ); + + expect(frontmatter).toMatchObject({ + authors: ["[[John Doe]]", "[[Jane Doe]]"], + }); + }); +});