mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f5bdcbd8 | ||
|
|
b6dbdbe9a5 | ||
|
|
21efb1d811 | ||
|
|
e2ee18893f | ||
|
|
be53b4b26e | ||
|
|
042c4c06e5 | ||
|
|
9d1c898b84 | ||
|
|
07b27c01e7 | ||
|
|
4f4cc69b9f | ||
|
|
3f81f22026 | ||
|
|
9331351c6d |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
crumbs:
|
crumbs:
|
||||||
image: ghcr.io/bretzel-app/crumbs:0.25.0
|
image: ghcr.io/bretzel-app/crumbs:0.27.0
|
||||||
container_name: crumbs
|
container_name: crumbs
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "crumbs",
|
"name": "crumbs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.26.0",
|
"version": "0.27.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Bretzel (https://bretzel.app)",
|
"author": "Bretzel (https://bretzel.app)",
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ export const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
|||||||
export const ADMIN = {
|
export const ADMIN = {
|
||||||
email: 'admin@bretzel.app',
|
email: 'admin@bretzel.app',
|
||||||
displayName: 'Bretzel Admin',
|
displayName: 'Bretzel Admin',
|
||||||
password: 'bretzel2026!'
|
password: crypto.randomUUID()
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COLLABORATOR = {
|
export const COLLABORATOR = {
|
||||||
email: 'salzig@bretzel.app',
|
email: 'salzig@bretzel.app',
|
||||||
displayName: 'Salzig Bretzel',
|
displayName: 'Salzig Bretzel',
|
||||||
password: 'salzig2026!!'
|
password: crypto.randomUUID()
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-1
@@ -143,7 +143,8 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Nested task lists take full width below the parent item */
|
/* Nested task lists take full width below the parent item */
|
||||||
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"] {
|
.tiptap ul[data-type="taskList"] li > ul[data-type="taskList"],
|
||||||
|
.prose ul.task-list li > ul.task-list {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
padding-left: 1.5rem;
|
padding-left: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -166,6 +167,7 @@ body::before {
|
|||||||
.prose ul.task-list .task-checkbox {
|
.prose ul.task-list .task-checkbox {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
margin-top: 0.2em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,100 @@
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let currentlyNew = $state(isNew);
|
let currentlyNew = $state(isNew);
|
||||||
|
|
||||||
|
// Auto-save: track last-saved state to detect real changes
|
||||||
|
let lastSavedTitle = note?.title ?? '';
|
||||||
|
let lastSavedContent = note?.content ?? '';
|
||||||
|
let lastSavedColor: NoteColor = note?.color ?? prefs.defaultNoteColor;
|
||||||
|
let lastSavedChecklistMode = note?.checklistMode ?? initialChecklistMode;
|
||||||
|
let isSaving = false;
|
||||||
|
let savingPromise: Promise<void> | null = null;
|
||||||
|
let autoSaveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// TiptapEditor fires onUpdate on init with round-tripped markdown that may differ
|
||||||
|
// from the original. Track whether auto-save should be active yet.
|
||||||
|
let autoSaveReady = isNew;
|
||||||
|
|
||||||
|
function hasUnsavedChanges(): boolean {
|
||||||
|
return (
|
||||||
|
title !== lastSavedTitle ||
|
||||||
|
content !== lastSavedContent ||
|
||||||
|
color !== lastSavedColor ||
|
||||||
|
checklistMode !== lastSavedChecklistMode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSave() {
|
||||||
|
if (isSaving) return;
|
||||||
|
// Snapshot current fields to avoid races with user edits during await
|
||||||
|
const snap = { title, content, color, checklistMode };
|
||||||
|
if (!snap.title.trim() && !snap.content.trim()) return;
|
||||||
|
if (
|
||||||
|
snap.title === lastSavedTitle &&
|
||||||
|
snap.content === lastSavedContent &&
|
||||||
|
snap.color === lastSavedColor &&
|
||||||
|
snap.checklistMode === lastSavedChecklistMode
|
||||||
|
) return;
|
||||||
|
|
||||||
|
isSaving = true;
|
||||||
|
try {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const created = await createNote(snap);
|
||||||
|
if (created) {
|
||||||
|
noteId = created.id;
|
||||||
|
currentlyNew = false;
|
||||||
|
lastSavedTitle = snap.title;
|
||||||
|
lastSavedContent = snap.content;
|
||||||
|
lastSavedColor = snap.color;
|
||||||
|
lastSavedChecklistMode = snap.checklistMode;
|
||||||
|
}
|
||||||
|
} else if (noteId) {
|
||||||
|
const updated = await updateNote(noteId, snap);
|
||||||
|
if (updated) {
|
||||||
|
lastSavedTitle = snap.title;
|
||||||
|
lastSavedContent = snap.content;
|
||||||
|
lastSavedColor = snap.color;
|
||||||
|
lastSavedChecklistMode = snap.checklistMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounced auto-save: triggers 2s after the user stops editing
|
||||||
|
$effect(() => {
|
||||||
|
const _t = title;
|
||||||
|
const _c = content;
|
||||||
|
const _col = color;
|
||||||
|
const _cm = checklistMode;
|
||||||
|
|
||||||
|
if (!autoSaveReady) {
|
||||||
|
// Absorb TiptapEditor's initial content normalization as the baseline
|
||||||
|
lastSavedContent = _c;
|
||||||
|
autoSaveReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_t === lastSavedTitle && _c === lastSavedContent && _col === lastSavedColor && _cm === lastSavedChecklistMode) return;
|
||||||
|
if (!_t.trim() && !_c.trim()) return;
|
||||||
|
|
||||||
|
autoSaveTimer = setTimeout(() => {
|
||||||
|
savingPromise = performSave();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(autoSaveTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safety net: save on tab close / navigation
|
||||||
|
$effect(() => {
|
||||||
|
function handleBeforeUnload() {
|
||||||
|
if (hasUnsavedChanges() && (title.trim() || content.trim())) {
|
||||||
|
performSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
});
|
||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||||
|
|
||||||
@@ -188,7 +282,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleAttachmentUpload(attachment: Attachment) {
|
function handleAttachmentUpload(attachment: Attachment) {
|
||||||
attachmentsList = [...attachmentsList, attachment];
|
const updated = [...attachmentsList, attachment];
|
||||||
|
attachmentsList = updated;
|
||||||
|
if (noteId) {
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
async function handleToggleFeatured(attachmentId: string, featured: boolean) {
|
||||||
@@ -200,9 +300,14 @@
|
|||||||
body: JSON.stringify({ featured })
|
body: JSON.stringify({ featured })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
attachmentsList = attachmentsList.map((a) =>
|
const updated = attachmentsList.map((a) =>
|
||||||
a.id === attachmentId ? { ...a, featured } : a
|
a.id === attachmentId ? { ...a, featured } : a
|
||||||
);
|
);
|
||||||
|
attachmentsList = updated;
|
||||||
|
// Keep the notes store in sync so the card reflects featured state
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to toggle featured:', err);
|
console.error('Failed to toggle featured:', err);
|
||||||
@@ -215,7 +320,11 @@
|
|||||||
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
await fetch(`/api/notes/${noteId}/attachments?attachmentId=${attachmentId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
attachmentsList = attachmentsList.filter((a) => a.id !== attachmentId);
|
const updated = attachmentsList.filter((a) => a.id !== attachmentId);
|
||||||
|
attachmentsList = updated;
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) => (n.id === noteId ? { ...n, attachments: updated } : n))
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to remove attachment:', err);
|
console.error('Failed to remove attachment:', err);
|
||||||
}
|
}
|
||||||
@@ -226,11 +335,11 @@
|
|||||||
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
bgStyle = `background-color: ${getNoteColor(color, getIsDarkMode())}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use pointer events to detect backdrop clicks. Unlike mouse events,
|
// Close on backdrop tap/click. On close, a transparent shield stays in the DOM
|
||||||
// pointer events are never synthetically re-dispatched from touch, so
|
// for 400ms to absorb the browser's delayed synthetic click that would otherwise
|
||||||
// the tap that opened the editor on mobile cannot ghost-click the overlay.
|
// pass through to NoteCards underneath (ghost click).
|
||||||
// Both pointerdown and pointerup must target the overlay itself to close.
|
|
||||||
let pointerdownOnOverlay = false;
|
let pointerdownOnOverlay = false;
|
||||||
|
let closing = $state(false);
|
||||||
|
|
||||||
function handleOverlayPointerdown(e: PointerEvent) {
|
function handleOverlayPointerdown(e: PointerEvent) {
|
||||||
pointerdownOnOverlay = e.target === e.currentTarget;
|
pointerdownOnOverlay = e.target === e.currentTarget;
|
||||||
@@ -241,35 +350,37 @@
|
|||||||
pointerdownOnOverlay = false;
|
pointerdownOnOverlay = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dismissOverlay() {
|
||||||
|
closing = true;
|
||||||
|
setTimeout(() => onClose(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAndClose() {
|
async function saveAndClose() {
|
||||||
|
clearTimeout(autoSaveTimer);
|
||||||
|
// Wait for any in-flight auto-save to finish
|
||||||
|
if (savingPromise) await savingPromise;
|
||||||
|
|
||||||
if (!title.trim() && !content.trim()) {
|
if (!title.trim() && !content.trim()) {
|
||||||
onClose();
|
dismissOverlay();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentlyNew) {
|
// Final save if auto-save hasn't caught up yet
|
||||||
await createNote({ title, content, color, checklistMode });
|
if (hasUnsavedChanges()) {
|
||||||
} else if (noteId) {
|
await performSave();
|
||||||
await updateNote(noteId, { title, content, color, checklistMode });
|
|
||||||
}
|
}
|
||||||
onClose();
|
dismissOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-save a new note without closing, returns the new note ID */
|
/** Auto-save a new note without closing, returns the new note ID */
|
||||||
async function autoSave(): Promise<string | null> {
|
async function autoSave(): Promise<string | null> {
|
||||||
if (!currentlyNew) return noteId;
|
if (!currentlyNew) return noteId;
|
||||||
const created = await createNote({
|
clearTimeout(autoSaveTimer);
|
||||||
title: title || 'Untitled',
|
if (savingPromise) await savingPromise;
|
||||||
content,
|
// For feature-triggered saves (image upload, share), use 'Untitled' fallback
|
||||||
color,
|
if (!title.trim()) title = 'Untitled';
|
||||||
checklistMode
|
await performSave();
|
||||||
});
|
return noteId;
|
||||||
if (created) {
|
|
||||||
noteId = created.id;
|
|
||||||
currentlyNew = false;
|
|
||||||
return created.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleImageUpload() {
|
async function toggleImageUpload() {
|
||||||
@@ -302,7 +413,7 @@
|
|||||||
showOverflowMenu = false;
|
showOverflowMenu = false;
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
await archiveNote(noteId);
|
await archiveNote(noteId);
|
||||||
onClose();
|
dismissOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +421,7 @@
|
|||||||
showOverflowMenu = false;
|
showOverflowMenu = false;
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
await trashNote(noteId);
|
await trashNote(noteId);
|
||||||
onClose();
|
dismissOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,12 +440,13 @@
|
|||||||
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
class="fixed inset-0 z-40 {closing ? '' : 'flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]'}"
|
||||||
onpointerdown={handleOverlayPointerdown}
|
onpointerdown={handleOverlayPointerdown}
|
||||||
onpointerup={handleOverlayPointerup}
|
onpointerup={handleOverlayPointerup}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
data-testid="note-editor-overlay"
|
data-testid="note-editor-overlay"
|
||||||
>
|
>
|
||||||
|
{#if !closing}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
class="mx-4 flex w-full max-w-xl md:max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||||
@@ -584,6 +696,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showShareDialog && noteId}
|
{#if showShareDialog && noteId}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { createTestDb } from './db/test-helpers.js';
|
|||||||
import { users, sessions } from './db/schema.js';
|
import { users, sessions } from './db/schema.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes, randomUUID } from 'crypto';
|
||||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||||
import type * as schema from './db/schema.js';
|
import type * as schema from './db/schema.js';
|
||||||
|
|
||||||
@@ -16,16 +16,16 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('Auth - Password Hashing', () => {
|
describe('Auth - Password Hashing', () => {
|
||||||
it('should hash and verify a password correctly', async () => {
|
it('should hash and verify a password correctly', async () => {
|
||||||
const password = 'mysecurepassword';
|
const password = randomUUID();
|
||||||
const hash = await argon2.hash(password);
|
const hash = await argon2.hash(password);
|
||||||
|
|
||||||
expect(hash).not.toBe(password);
|
expect(hash).not.toBe(password);
|
||||||
expect(await argon2.verify(hash, password)).toBe(true);
|
expect(await argon2.verify(hash, password)).toBe(true);
|
||||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
expect(await argon2.verify(hash, randomUUID())).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should produce different hashes for the same password', async () => {
|
it('should produce different hashes for the same password', async () => {
|
||||||
const password = 'mysecurepassword';
|
const password = randomUUID();
|
||||||
const hash1 = await argon2.hash(password);
|
const hash1 = await argon2.hash(password);
|
||||||
const hash2 = await argon2.hash(password);
|
const hash2 = await argon2.hash(password);
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ describe('Auth - Password Hashing', () => {
|
|||||||
|
|
||||||
describe('Auth - User Setup', () => {
|
describe('Auth - User Setup', () => {
|
||||||
it('should create a user with hashed password', async () => {
|
it('should create a user with hashed password', async () => {
|
||||||
const password = 'testpassword123';
|
const password = randomUUID();
|
||||||
const hash = await argon2.hash(password);
|
const hash = await argon2.hash(password);
|
||||||
|
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
|
import { test, expect, setupAndLogin } from './helpers/fixtures.js';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { TEST_CREDENTIALS_FILE } from './global-setup';
|
||||||
|
|
||||||
|
const { userPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||||
|
|
||||||
test.describe.serial('Authentication', () => {
|
test.describe.serial('Authentication', () => {
|
||||||
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
|
test('Scenario: Unauthenticated user is prompted to set up or log in', async ({ page }) => {
|
||||||
@@ -78,7 +82,7 @@ test.describe('Multi-user', () => {
|
|||||||
data: {
|
data: {
|
||||||
email: 'user2@test.com',
|
email: 'user2@test.com',
|
||||||
displayName: 'User Two',
|
displayName: 'User Two',
|
||||||
password: 'userpassword123',
|
password: userPassword,
|
||||||
role: 'user'
|
role: 'user'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test, expect, noteCard, createNote } from './helpers/fixtures.js';
|
||||||
|
|
||||||
|
test.describe('Auto-save', () => {
|
||||||
|
test('Scenario: New note is auto-saved without closing the editor', async ({ authenticatedPage: page }) => {
|
||||||
|
// When the user starts a new note and types a title and content
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
await page.getByTestId('note-title-input').fill('Auto-saved Note');
|
||||||
|
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially('This should be saved automatically');
|
||||||
|
|
||||||
|
// Then the note is persisted after the auto-save delay (2s debounce)
|
||||||
|
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// And closing the editor preserves the note
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
await expect(noteCard(page, 'Auto-saved Note')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Edits to an existing note are auto-saved', async ({ authenticatedPage: page }) => {
|
||||||
|
// Given a note titled "Edit Me" exists
|
||||||
|
await createNote(page, 'Edit Me', 'Original content');
|
||||||
|
|
||||||
|
// When the user opens the note and changes the title
|
||||||
|
await noteCard(page, 'Edit Me').click();
|
||||||
|
await page.getByTestId('note-title-input').clear();
|
||||||
|
await page.getByTestId('note-title-input').fill('Edited Title');
|
||||||
|
|
||||||
|
// Then the updated title is auto-saved after the debounce delay
|
||||||
|
// Wait for the API call that confirms the save
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => res.url().includes('/api/notes/') && res.request().method() === 'PATCH',
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the note card reflects the change while editor is still open
|
||||||
|
await expect(noteCard(page, 'Edited Title')).toBeVisible();
|
||||||
|
|
||||||
|
// Cleanup: close the editor
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Empty note is not auto-saved', async ({ authenticatedPage: page }) => {
|
||||||
|
// Given the current number of notes
|
||||||
|
const noteCards = page.locator('[data-testid="note-card"]');
|
||||||
|
const countBefore = await noteCards.count();
|
||||||
|
|
||||||
|
// When the user opens a new note but leaves it empty
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
|
||||||
|
// And waits longer than the auto-save delay
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// And closes the empty editor
|
||||||
|
await page.getByTestId('close-editor-btn').click();
|
||||||
|
|
||||||
|
// Then no new note was created
|
||||||
|
const countAfter = await noteCards.count();
|
||||||
|
expect(countAfter).toBe(countBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Scenario: Auto-save persists note across page reload', async ({ authenticatedPage: page }) => {
|
||||||
|
// When the user creates a note and waits for auto-save
|
||||||
|
await page.getByTestId('new-note-btn').click();
|
||||||
|
await page.getByTestId('note-title-input').fill('Auto-save Survives Reload');
|
||||||
|
const editor = page.getByTestId('tiptap-editor').locator('.tiptap');
|
||||||
|
await editor.click();
|
||||||
|
await editor.pressSequentially('Content that survives');
|
||||||
|
|
||||||
|
// Wait for auto-save to fire
|
||||||
|
await page.waitForResponse(
|
||||||
|
(res) => res.url().includes('/api/notes') && res.request().method() === 'POST',
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the page is reloaded without closing the editor
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Then the note is still visible
|
||||||
|
await expect(noteCard(page, 'Auto-save Survives Reload')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { existsSync, unlinkSync } from 'fs';
|
import { existsSync, unlinkSync, writeFileSync } from 'fs';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
const TEST_DB = './data/test-crumbs.db';
|
const TEST_DB = './data/test-crumbs.db';
|
||||||
|
export const TEST_CREDENTIALS_FILE = './data/test-credentials.json';
|
||||||
|
|
||||||
export default function globalSetup() {
|
export default function globalSetup() {
|
||||||
// Clean test database before E2E run so each run starts fresh
|
// Clean test database before E2E run so each run starts fresh
|
||||||
@@ -10,4 +12,14 @@ export default function globalSetup() {
|
|||||||
unlinkSync(path);
|
unlinkSync(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate random passwords for this test run, shared across all workers
|
||||||
|
writeFileSync(
|
||||||
|
TEST_CREDENTIALS_FILE,
|
||||||
|
JSON.stringify({
|
||||||
|
testPassword: `test-${randomUUID()}`,
|
||||||
|
collabPassword: `collab-${randomUUID()}`,
|
||||||
|
userPassword: `user-${randomUUID()}`
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
import { collectCoverage } from './coverage';
|
import { collectCoverage } from './coverage';
|
||||||
|
import { TEST_CREDENTIALS_FILE } from '../global-setup';
|
||||||
|
|
||||||
|
const { testPassword, collabPassword } = JSON.parse(readFileSync(TEST_CREDENTIALS_FILE, 'utf-8'));
|
||||||
|
|
||||||
const TEST_EMAIL = 'admin@test.com';
|
const TEST_EMAIL = 'admin@test.com';
|
||||||
const TEST_PASSWORD = 'testpassword123';
|
const TEST_PASSWORD = testPassword;
|
||||||
const TEST_DISPLAY_NAME = 'Test Admin';
|
const TEST_DISPLAY_NAME = 'Test Admin';
|
||||||
|
|
||||||
const COLLAB_EMAIL = 'collab@test.com';
|
const COLLAB_EMAIL = 'collab@test.com';
|
||||||
const COLLAB_PASSWORD = 'collabpass123';
|
const COLLAB_PASSWORD = collabPassword;
|
||||||
const COLLAB_NAME = 'Collaborator User';
|
const COLLAB_NAME = 'Collaborator User';
|
||||||
|
|
||||||
/** Find a specific note card by its title text */
|
/** Find a specific note card by its title text */
|
||||||
|
|||||||
+1
-1
@@ -292,7 +292,7 @@
|
|||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/docs/DEPLOYMENT.md" target="_blank" rel="noopener">Docs</a></li>
|
||||||
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
<li><a href="https://github.com/bretzel-app/crumbs/blob/main/LICENSE" target="_blank" rel="noopener">License</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<span class="footer-version">v0.26.0</span>
|
<span class="footer-version">v0.27.1</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user