diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 712303e..c3d6237 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -191,7 +191,7 @@ development_status: epic-5-retro-item-4-permission-toml-auto-gen-check: done # MOOT at Epic 6 retro — Epic 6 added no new Tauri commands (IPC delegates to existing services), gotcha did not recur # Epic 6 Retro Items (2026-06-14): - epic-6-retro-item-1-first-feature-e2e: done # HIGH — feature E2E green on CI at HEAD 3a76bac (run 27566817463, Linux job): 16/16 incl. trash-lifecycle (P1-E2E-002) AND CLI live-sync (P1-E2E-003). [Corrected 2026-06-16: the earlier note cited HEAD 5be24c7 as the green run, but that commit's CI is FAILURE and predates the live-sync suite (added in 322e570); and the live-sync suite shipped — it was never "deferred". DW-91/DW-92 are trash-suite hygiene follow-ups, not a live-sync deferral.] Specs spec-6-retro-1-first-feature-e2e.md + spec-cli-live-sync-e2e.md status done. Local verification 2026-06-16: 16/16 under xvfb after fixing a modern-WebKitWebDriver socket-rendezvous bug (env-reset; see DW-95) — the suite no longer "crashes under local xvfb". Export-dialog E2E remains the one unbuilt path. Pinkyd-owned. + epic-6-retro-item-1-first-feature-e2e: done # HIGH — feature E2E green on CI at HEAD 3a76bac (run 27566817463, Linux job): 16/16 incl. trash-lifecycle (P1-E2E-002) AND CLI live-sync (P1-E2E-003). [Corrected 2026-06-16: the earlier note cited HEAD 5be24c7 as the green run, but that commit's CI is FAILURE and predates the live-sync suite (added in 322e570); and the live-sync suite shipped — it was never "deferred". DW-91/DW-92 are trash-suite hygiene follow-ups, not a live-sync deferral.] Specs spec-6-retro-1-first-feature-e2e.md + spec-cli-live-sync-e2e.md status done. Local verification 2026-06-16: 16/16 under xvfb after fixing a modern-WebKitWebDriver socket-rendezvous bug (env-reset; see DW-95) — the suite no longer "crashes under local xvfb". Export E2E built 2026-06-16 (P1-E2E-004 in e2e/run.mjs): 5.E2E-002 (markdown file-write) + 5.E2E-003 (json file-write), 19/19 green under local xvfb. Approach is direct-command-invoke (executeScript → invoke export_markdown/export_json with a temp path → assert real on-disk files), per the epic-5 test-design fallback: the native OS picker is un-drivable via WebDriver AND a JS dialog-stub is impossible because Tauri v2 freezes __TAURI_INTERNALS__ (invoke/postMessage are non-writable+non-configurable, verified on the running binary), so no in-page seam exists to fake the picker. Picker cancel/null branch stays covered by the export frontend unit tests. Pinkyd-owned. epic-6-retro-item-2-singleflight-guard-helper: done # HIGH — shared singleflight helper extracted (src/lib/singleflight.ts) + all 7 hand-rolled guards migrated (realtimeSync refreshInFlight/refreshQueued, command-palette isCreatingNote/isTrashingNote/isTogglingTheme/isTogglingLayoutMode, export isExporting); spec-singleflight-guard-helper.md status done, merged b6a0c06/86f2ff4. Verified 2026-06-15: acceptance grep clean (zero legacy-guard hits outside singleflight.ts), tsc clean, 572 FE tests pass. Absorbs epic-5-retro-item-2. Pinkyd-owned. epic-6-retro-item-3-per-story-dw-logging: backlog # MEDIUM — log every real out-of-scope review dismissal to deferred-work.md PER STORY as part of the coding-assistant workflow (not swept at retro) epic-6-retro-item-4-close-out-tracking-discipline: done # MEDIUM — reconcile status on close-out not at next retro; Epic 5 items 1/2/4 reconciled this retro diff --git a/_bmad-output/test-artifacts/test-design-epic-5.md b/_bmad-output/test-artifacts/test-design-epic-5.md index 18062f1..ff0e2d7 100644 --- a/_bmad-output/test-artifacts/test-design-epic-5.md +++ b/_bmad-output/test-artifacts/test-design-epic-5.md @@ -154,7 +154,7 @@ project-context and out of this epic's new scope. - [ ] All P0 baseline (existing) tests still pass (no regression) - [ ] All P1 backfill scenarios passing (≥95%); RISK-E5-001 mitigation green -- [ ] `5.E2E-001` (trash lifecycle) and `5.E2E-002` (markdown file-write) green — first feature E2E +- [x] `5.E2E-001` (trash lifecycle) and `5.E2E-002` (markdown file-write) green — first feature E2E - [ ] dedup→toast regression net (`5.2-COMP-002`, `5.3-COMP-003/004`) green - [ ] No open high-priority (≥6) items unmitigated - [ ] 10k/30s export benches recorded (advisory baseline, not a blocker) @@ -293,8 +293,8 @@ boundary are all unit-tested. **Total NEW P0: 0.** | ID | Scenario | Level | Pri | AC/Risk | Status | |----|----------|-------|-----|---------|--------| | 5.E2E-001 | Trash lifecycle: trash (toast, tab closes) → view → restore → trash → permanent delete → gone from list + FTS | E2E | P1 | RISK-E5-002 | 🆕 | -| 5.E2E-002 | Markdown export file-write: `.md`/note on disk, frontmatter+body, safe filenames, **confined to dir** | E2E | P1 | RISK-E5-001/002 | 🆕 | -| 5.E2E-003 | JSON export file-write: re-parse valid array, 7 fields, `workspaceName:null` loose note, confined to dir | E2E | P2 | RISK-E5-001/002/007 | 🆕 | +| 5.E2E-002 | Markdown export file-write: `.md`/note on disk, frontmatter+body, safe filenames, **confined to dir** | E2E | P1 | RISK-E5-001/002 | ✅ done — P1-E2E-004 in e2e/run.mjs (direct-command-invoke; native picker un-drivable) | +| 5.E2E-003 | JSON export file-write: re-parse valid array, 7 fields, `workspaceName:null` loose note, confined to dir | E2E | P2 | RISK-E5-001/002/007 | ✅ done — P1-E2E-004 in e2e/run.mjs | | 5.E2E-004 | Startup auto-purge after restart: aged gone, fresh survives, silent | E2E | P2 | RISK-E5-004 | 🆕 | --- diff --git a/e2e/driver.mjs b/e2e/driver.mjs index 2363fc9..dfd4f87 100644 --- a/e2e/driver.mjs +++ b/e2e/driver.mjs @@ -153,6 +153,19 @@ export async function executeScript(sessionId, script, args = []) { return request('POST', `/session/${sessionId}/execute/sync`, { script, args }); } +/** + * Run an asynchronous script in the page and return what it passes to its + * completion callback. The callback is the script's LAST argument + * (`arguments[arguments.length - 1]`); the command resolves when the script + * invokes it. Unlike `executeScript` (sync), this is the W3C-standard way to + * await a promise — used to drive a real Tauri command (`__TAURI_INTERNALS__.invoke`) + * and observe its resolved value without depending on a particular driver's + * (non-standard) sync-await-of-thenables behavior. + */ +export async function executeAsyncScript(sessionId, script, args = []) { + return request('POST', `/session/${sessionId}/execute/async`, { script, args }); +} + export function pause(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/e2e/run.mjs b/e2e/run.mjs index 51db4fe..fe3e1b6 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -51,6 +51,7 @@ import { getElementText, getElementProperty, executeScript, + executeAsyncScript, isElementDisplayed, sendKeysToElement, sendSpecialKey, @@ -154,6 +155,31 @@ async function runPaletteCommand(label) { await pause(300); } +/** + * Invoke a Tauri command over the app's real IPC bridge from inside the page and + * resolve with `{ ok, value }` or `{ ok:false, err }`. Used by the export suite: + * the export *flow* opens a native OS file picker, and neither the picker nor any + * JS interception of it is drivable — Tauri v2 freezes `__TAURI_INTERNALS__` + * (its `invoke`/`postMessage` are non-writable, non-configurable), so the picker + * cannot be stubbed in-page. We therefore drive the command directly with a + * harness-chosen temp path (the picker's only contribution to the flow), exactly + * as the epic-5 test design prescribes — still exercising the packaged binary's + * real command and a real on-disk write. The picker's cancel/`null` branch stays + * covered by the frontend unit tests. Uses `execute/async` (the callback is the + * script's last argument) so the command's resolved value is awaited per the W3C + * standard — not via any driver-specific sync-await-of-thenables behavior. + */ +async function invokeCommand(cmd, args) { + return executeAsyncScript( + sessionId, + `var done = arguments[arguments.length - 1]; + window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1]) + .then(function (v) { done({ ok: true, value: v }); }) + .catch(function (e) { done({ ok: false, err: String((e && e.message) || e) }); });`, + [cmd, args], + ); +} + /** * Among elements matching `selector`, find the one whose text contains `marker` * and return its `data-testid`. Matches against the `textContent` property — not @@ -587,6 +613,97 @@ async function cliLiveSyncTests() { } } +async function exportTests() { + console.log('\nP1-E2E-004: Export (file-write)'); + + // Unique per run so the marker note is identifiable in the non-isolated dev DB, + // and so its text is findable in the exported files regardless of how the title + // is derived (the marker lands in the note body). + const marker = `E2E-EXPORT-${Date.now()}`; + + // Harness-side temp targets — the path the OS picker would have returned. Created + // on the same filesystem the app process sees, passed to the real export command, + // then read back here to prove the packaged binary genuinely wrote them. Removed + // in `finally`. + const mdDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notey-e2e-md-')); + const jsonDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notey-e2e-json-')); + const jsonFile = path.join(jsonDir, 'out.json'); + + try { + await test('create a note tagged with a unique marker', async () => { + await sendChord(sessionId, CONTROL, 'n'); // New Note → fresh active tab + await pause(400); + const contentId = await waitForCss('.cm-content'); + await sendKeysToElement(sessionId, contentId, marker); + await pause(800); // 300ms auto-save debounce + save round-trip + const text = await getElementText(sessionId, contentId); + assert(text.includes(marker), `Editor should contain marker, got: "${text}"`); + }); + + await test('export_markdown writes per-note files to the target dir (5.E2E-002)', async () => { + const res = await invokeCommand('export_markdown', { directory: mdDir }); + assert(res.ok, `export_markdown failed: ${res.err}`); + assert(typeof res.value === 'number' && res.value >= 1, `expected ≥1 files written, got ${res.value}`); + + const files = fs.readdirSync(mdDir); + const mdFiles = files.filter((f) => f.endsWith('.md')); + assert(mdFiles.length >= 1, `expected ≥1 .md file in ${mdDir}, saw: ${files.join(', ') || '(none)'}`); + + // Filenames stay confined to the dir (basenames only, no traversal / separators). + for (const f of mdFiles) { + assert(/^[^/\\]+\.md$/.test(f), `unsafe export filename: "${f}"`); + } + + // Exactly one file is *our* note (matched by body marker), and it carries + // YAML frontmatter (title+format) ahead of the body. + const ours = mdFiles + .map((f) => ({ f, body: fs.readFileSync(path.join(mdDir, f), 'utf8') })) + .filter((x) => x.body.includes(marker)); + assert(ours.length === 1, `expected exactly 1 exported file containing the marker, found ${ours.length}`); + const { body } = ours[0]; + assert(body.startsWith('---'), 'exported markdown should open with YAML frontmatter'); + assert(/\ntitle:/.test(body) && /\nformat:/.test(body), 'frontmatter should carry title and format'); + // The note title derives from its content, so the marker also appears in the + // frontmatter `title:` line — assert it in the *body*, after the closing fence. + const fenceClose = body.indexOf('\n---\n', 3); + assert(fenceClose !== -1, 'markdown should have a closing frontmatter fence'); + const afterFrontmatter = body.slice(fenceClose + '\n---\n'.length); + assert(afterFrontmatter.includes(marker), 'marker should appear in the note body (after frontmatter)'); + }); + + await test('export_json writes a single re-parseable array file (5.E2E-003)', async () => { + const res = await invokeCommand('export_json', { filePath: jsonFile }); + assert(res.ok, `export_json failed: ${res.err}`); + assert(typeof res.value === 'number' && res.value >= 1, `expected ≥1 notes written, got ${res.value}`); + + const raw = fs.readFileSync(jsonFile, 'utf8'); + const parsed = JSON.parse(raw); // re-parse proves valid JSON on disk + assert(Array.isArray(parsed), 'JSON export should be an array'); + // 2-space pretty-print: array elements indent 2, object keys indent 4. The + // shared dev DB always has ≥1 active note (ours), so the array is non-empty. + assert(raw.startsWith('[\n {') && /\n "/.test(raw), 'JSON export should be 2-space indented'); + + const entry = parsed.find((n) => typeof n?.content === 'string' && n.content.includes(marker)); + assert(entry, 'exported JSON should contain our marker note'); + for (const field of ['id', 'title', 'content', 'format', 'workspaceName', 'createdAt', 'updatedAt']) { + assert(field in entry, `exported note is missing field "${field}"`); + } + }); + } finally { + // Trash + permanently purge the marker note so the shared dev DB stays clean + // (purgeCliNote loads it from the list, trashes it, then hard-deletes — exactly + // the active-note teardown we need here). Then drop the temp dirs. Best-effort. + await purgeCliNote(marker); + for (const dir of [mdDir, jsonDir]) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* temp cleanup is best-effort */ + } + } + } +} + // --- Main --- async function main() { @@ -636,6 +753,15 @@ async function main() { await pause(3000); await cliLiveSyncTests(); + + // Fresh session for the export feature suite. + await deleteSession(sessionId); + await pause(2000); + + sessionId = await createSession(APP_PATH); + await pause(3000); + + await exportTests(); } catch (e) { console.error('Fatal error:', e.message); failed++;