Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion _bmad-output/implementation-artifacts/sprint-status.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions _bmad-output/test-artifacts/test-design-epic-5.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 | 🆕 |

---
Expand Down
13 changes: 13 additions & 0 deletions e2e/driver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
126 changes: 126 additions & 0 deletions e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
getElementText,
getElementProperty,
executeScript,
executeAsyncScript,
isElementDisplayed,
sendKeysToElement,
sendSpecialKey,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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++;
Expand Down