mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
Compare commits
@@ -18,6 +18,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -34,4 +36,87 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Run release script
|
||||
run: bash scripts/release.sh ${{ github.event.inputs.bump }}
|
||||
env:
|
||||
BUMP_TYPE: ${{ github.event.inputs.bump }}
|
||||
run: bash scripts/release.sh "$BUMP_TYPE"
|
||||
|
||||
- name: Output new tag
|
||||
id: release-tag
|
||||
run: echo "tag=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
ci:
|
||||
needs: bump
|
||||
uses: ./.github/workflows/ci.yml
|
||||
|
||||
release:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, ci]
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.bump.outputs.tag }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=${{ needs.bump.outputs.tag }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.bump.outputs.tag }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.bump.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump, release]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ needs.bump.outputs.tag }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
|
||||
@@ -55,10 +55,27 @@ jobs:
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
pin-docker-compose:
|
||||
name: Pin docker-compose to released version
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Trigger Dokploy redeploy
|
||||
run: curl -X POST "${{ secrets.DOKPLOY_WEBHOOK_URL }}"
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update docker-compose.yml
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
sed -i "s|image: ghcr.io/bretzel-app/crumbs:.*|image: ghcr.io/bretzel-app/crumbs:${VERSION}|" docker-compose.yml
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add docker-compose.yml
|
||||
git diff --cached --quiet && exit 0
|
||||
git commit -m "chore: pin docker-compose to ${TAG} [skip ci]"
|
||||
git push origin main
|
||||
|
||||
@@ -135,10 +135,14 @@ GitHub Actions with two workflows:
|
||||
4. **E2E Tests** — Playwright with Chromium (depends on step 3, uploads report on failure)
|
||||
5. **Docker Build** — validates the Docker image builds (depends on step 3)
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags:
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io` (GitHub Container Registry)
|
||||
**Bump & Release** (`.github/workflows/bump.yml`) — workflow_dispatch (primary release method):
|
||||
- Runs `scripts/release.sh` to bump version in `package.json` + `docker-compose.yml`, tag, and push
|
||||
- Runs full CI pipeline, then builds + pushes Docker image to `ghcr.io`
|
||||
- Creates a GitHub Release with auto-generated notes
|
||||
|
||||
**Release** (`.github/workflows/release.yml`) — runs on `v*` tags (fallback for manual/local tag pushes):
|
||||
- Same as above: CI pipeline, Docker build/push, GitHub Release
|
||||
|
||||
**Docker**: Multi-stage Dockerfile (node:22-slim), exposes port 3000, persists data to `/data` volume.
|
||||
|
||||
## Testing philosophy: BDD/TDD
|
||||
@@ -188,3 +192,14 @@ E2E tests follow Cucumber BDD conventions via comments. Follow the [Better Gherk
|
||||
```
|
||||
|
||||
**Resilience test** — ask: *"Will this comment need to change if the UI implementation changes?"* If yes, rewrite it to remove implementation details. The Playwright code underneath handles the *how*; the comments describe the *what*.
|
||||
|
||||
## Documentation
|
||||
|
||||
Keep docs in `docs/` up to date when making changes. If a feature, API endpoint, deployment config, or architectural decision changes, update the relevant doc file in the same commit or PR:
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| `docs/FEATURES.md` | User-facing feature list |
|
||||
| `docs/ARCHITECTURE.md` | System design, data flow, schema, tech stack |
|
||||
| `docs/DEPLOYMENT.md` | Docker, env vars, reverse proxy, backups |
|
||||
| `docs/openapi.yaml` | API spec (run `pnpm docs:api` to regenerate `docs/API.md`) |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:0.3.2
|
||||
container_name: crumbs
|
||||
ports:
|
||||
- "3000:3000"
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
- NODE_ENV=production
|
||||
- DATA_DIR=/data
|
||||
- DATABASE_URL=/data/crumbs.db
|
||||
- ORIGIN=http://localhost:3000
|
||||
- ORIGIN=${ORIGIN:-http://localhost:3000}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
+24
@@ -103,6 +103,7 @@ List notes
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -125,6 +126,7 @@ Create a note
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -147,6 +149,7 @@ Create a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -181,6 +184,7 @@ Get a note
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -212,6 +216,7 @@ Partial update — only include changed fields.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -234,6 +239,7 @@ Partial update — only include changed fields.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -288,6 +294,19 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the
|
||||
|
||||
**Response:** `200`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "id",
|
||||
"noteId": "noteId",
|
||||
"filename": "filename",
|
||||
"mimeType": "mimeType",
|
||||
"size": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/notes/{id}/attachments`
|
||||
@@ -366,6 +385,7 @@ Searches across note titles, content, and tags. Excludes trashed notes.
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -423,6 +443,7 @@ Pull changes since timestamp
|
||||
"pinned": true,
|
||||
"archived": true,
|
||||
"trashed": true,
|
||||
"trashedAt": "2025-01-01T00:00:00.000Z",
|
||||
"checklistMode": true,
|
||||
"sortOrder": 1,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
@@ -451,6 +472,7 @@ Push local changes
|
||||
"operation": "create",
|
||||
"timestamp": 1,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "title",
|
||||
"content": "content",
|
||||
"color": "default",
|
||||
@@ -473,4 +495,6 @@ Push local changes
|
||||
}
|
||||
```
|
||||
|
||||
**Errors:** `400` Invalid changes payload
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Open http://localhost:3000 and set your password.
|
||||
# docker-compose.yml
|
||||
services:
|
||||
crumbs:
|
||||
build: .
|
||||
image: ghcr.io/bretzel-app/crumbs:latest
|
||||
ports:
|
||||
- "8080:3000" # Change external port
|
||||
volumes:
|
||||
|
||||
+15
-4
@@ -8,16 +8,27 @@
|
||||
- Content supports full Markdown syntax
|
||||
- Auto-save on editor close
|
||||
|
||||
### Markdown Support
|
||||
- **Bold** (`**text**`)
|
||||
- *Italic* (`*text*`)
|
||||
### Rich Text Editor
|
||||
- WYSIWYG editing powered by Tiptap
|
||||
- Formatting toolbar with visual controls
|
||||
- Raw Markdown mode toggle for direct editing
|
||||
- Undo/Redo with keyboard shortcuts
|
||||
|
||||
### Text Formatting
|
||||
- **Bold** (`**text**` / Ctrl+B)
|
||||
- *Italic* (`*text*` / Ctrl+I)
|
||||
- Underline (Ctrl+U)
|
||||
- ~~Strikethrough~~ (`~~text~~` / Ctrl+Shift+X)
|
||||
- Headings (`# H1` through `###### H6`)
|
||||
- Blockquotes (`> quote`)
|
||||
- Unordered lists (`- item`)
|
||||
- Ordered lists (`1. item`)
|
||||
- Code blocks (triple backtick)
|
||||
- Inline code (single backtick)
|
||||
- Tables
|
||||
- Links (auto-linkified URLs)
|
||||
- Links — insert, edit, and remove with URL input
|
||||
- Dividers (horizontal rules)
|
||||
- Text alignment (left, center, right, justify)
|
||||
- Line breaks
|
||||
|
||||
### Checklists / Task Lists
|
||||
|
||||
+17
-1
@@ -187,7 +187,13 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Attachment list or file content
|
||||
description: Attachment list (without attachmentId) or file content (with attachmentId)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Attachment'
|
||||
post:
|
||||
summary: Upload an image attachment
|
||||
tags: [Attachments]
|
||||
@@ -306,6 +312,8 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Success'
|
||||
'400':
|
||||
description: Invalid changes payload
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
@@ -340,6 +348,10 @@ components:
|
||||
type: boolean
|
||||
trashed:
|
||||
type: boolean
|
||||
trashedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
checklistMode:
|
||||
type: boolean
|
||||
sortOrder:
|
||||
@@ -360,6 +372,10 @@ components:
|
||||
NoteInput:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Optional — generated if omitted
|
||||
title:
|
||||
type: string
|
||||
content:
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "crumbs",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+6
-4
@@ -6,10 +6,12 @@
|
||||
<meta name="theme-color" content="#f0e6d3" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🥨</text></svg>"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Crumbs" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<title>Crumbs</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
data-testid="format-hr"
|
||||
>
|
||||
<Minus size={iconSize} />
|
||||
<span>Horizontal rule</span>
|
||||
<span>Divider</span>
|
||||
</button>
|
||||
<div class="my-1 h-px bg-[var(--border-subtle)]"></div>
|
||||
<button
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" class="h-8 w-8" />
|
||||
<h1 class="font-['Press_Start_2P'] text-lg text-[var(--primary)]">Crumbs</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
{#if $notesLoaded && $pinnedNotes.length === 0 && $unpinnedNotes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-20 text-[var(--text-muted)]">
|
||||
<span class="mb-4 text-7xl">🥨</span>
|
||||
<img src="/favicon.svg" alt="" class="mb-4 h-18 w-18" />
|
||||
<p class="font-['Press_Start_2P'] text-xs">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
No crumbs in trash
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Enter your password to continue</p>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 shadow-[var(--card-shadow)]">
|
||||
<div class="mb-6 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="font-['Press_Start_2P'] text-xl text-[var(--primary)]">Welcome to Crumbs</h1>
|
||||
<p class="mt-2 text-sm text-[var(--text-muted)]">Set up your password to get started</p>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
|
||||
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
|
||||
<span class="text-5xl">🥨</span>
|
||||
<img src="/favicon.svg" alt="Crumbs" class="mx-auto h-12 w-12" />
|
||||
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
|
||||
{page.status}
|
||||
</h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types.js';
|
||||
import { processSyncPush, getChangesSince } from '$lib/sync/server.js';
|
||||
import { fetchTagsForNotes } from '$lib/server/tags.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { changes } = await request.json();
|
||||
@@ -16,5 +17,12 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const since = parseInt(url.searchParams.get('since') || '0', 10);
|
||||
const changes = await getChangesSince(since);
|
||||
return json(changes);
|
||||
|
||||
const tagMap = fetchTagsForNotes(changes.map((n) => n.id));
|
||||
const changesWithTags = changes.map((note) => ({
|
||||
...note,
|
||||
tags: tagMap.get(note.id) ?? []
|
||||
}));
|
||||
|
||||
return json(changesWithTags);
|
||||
};
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Crumbs by Bretzel",
|
||||
"short_name": "Crumbs",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#f9f4ea",
|
||||
"background_color": "#f9f4ea",
|
||||
"display": "standalone"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Checklist', () => {
|
||||
test('Scenario: Checklist UI replaces editor when checklist mode is enabled', async ({ authenticatedPage: page }) => {
|
||||
@@ -27,7 +27,7 @@ test.describe('Checklist', () => {
|
||||
await expect(page.getByText('Shopping List')).toBeVisible();
|
||||
|
||||
// When the user reopens the note
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Shopping List').click();
|
||||
|
||||
// Then the checklist is displayed with the saved item
|
||||
await expect(page.getByTestId('checklist')).toBeVisible();
|
||||
@@ -43,12 +43,12 @@ test.describe('Checklist', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user reopens the note and checks the item
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await page.getByTestId('checklist-checkbox').first().check();
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then reopening the note shows the item is still checked
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Tasks').click();
|
||||
await expect(page.getByTestId('checklist-checkbox').first()).toBeChecked();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
import type { Page, Locator } from '@playwright/test';
|
||||
|
||||
/** Run a TipTap command chain via the exposed editor instance on the DOM element */
|
||||
@@ -94,7 +94,7 @@ test.describe('Rich text formatting', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user reopens the note
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Rich Text Note').click();
|
||||
|
||||
// Then the content is rendered as rich text in the TipTap editor
|
||||
await expect(page.getByTestId('tiptap-editor')).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test';
|
||||
|
||||
const TEST_PASSWORD = 'testpassword123';
|
||||
|
||||
/** Find a specific note card by its title text */
|
||||
export function noteCard(page: Page, title: string): Locator {
|
||||
return page.locator('[data-testid="note-card"]', { hasText: title });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture that handles setup/auth.
|
||||
*/
|
||||
@@ -40,6 +45,10 @@ export async function setupAndLogin(page: Page) {
|
||||
await page.waitForURL('/');
|
||||
}
|
||||
// else: already on main page (session still valid)
|
||||
|
||||
// Wait for full hydration after auth redirect (window.location.href causes full reload).
|
||||
// networkidle ensures JS bundles have loaded and executed.
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
export { expect };
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Notes CRUD', () => {
|
||||
test('Scenario: Fresh account shows an empty notes list', async ({ authenticatedPage: page }) => {
|
||||
// Given the user has no notes
|
||||
// Then the empty state message is displayed
|
||||
await expect(page.getByText('No crumbs yet')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: New note appears in the notes list after creation', async ({ authenticatedPage: page }) => {
|
||||
// When the user creates a note titled "My First Note" with content "Hello world!"
|
||||
await page.getByTestId('new-note-btn').click();
|
||||
@@ -17,8 +11,7 @@ test.describe('Notes CRUD', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note is visible in the notes list
|
||||
await expect(page.getByTestId('note-card')).toBeVisible();
|
||||
await expect(page.getByText('My First Note')).toBeVisible();
|
||||
await expect(noteCard(page, 'My First Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Edited note title is reflected in the notes list', async ({ authenticatedPage: page }) => {
|
||||
@@ -31,7 +24,7 @@ test.describe('Notes CRUD', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user changes the title to "Updated Title"
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Original Title').click();
|
||||
await page.getByTestId('note-title-input').clear();
|
||||
await page.getByTestId('note-title-input').fill('Updated Title');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
@@ -47,8 +40,9 @@ test.describe('Notes CRUD', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user trashes the note
|
||||
await page.getByTestId('note-card').hover();
|
||||
await page.getByTestId('trash-btn').click();
|
||||
const card = noteCard(page, 'Delete Me');
|
||||
await card.hover();
|
||||
await card.getByTestId('trash-btn').click();
|
||||
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Delete Me')).not.toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Offline Support', () => {
|
||||
test('Scenario: Sync status is visible to the user', async ({ authenticatedPage: page }) => {
|
||||
@@ -28,7 +28,7 @@ test.describe('Offline Support', () => {
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// And the user edits the note title to "Offline Edited"
|
||||
await page.getByTestId('note-card').click();
|
||||
await noteCard(page, 'Offline Edit').click();
|
||||
await page.getByTestId('note-title-input').clear();
|
||||
await page.getByTestId('note-title-input').fill('Offline Edited');
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
@@ -107,8 +107,9 @@ test.describe('Offline Support', () => {
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// And the user trashes the note
|
||||
await page.getByTestId('note-card').hover();
|
||||
await page.getByTestId('trash-btn').click();
|
||||
const card = noteCard(page, 'Offline Trash');
|
||||
await card.hover();
|
||||
await card.getByTestId('trash-btn').click();
|
||||
|
||||
// Then the note is no longer visible
|
||||
await expect(page.getByText('Offline Trash')).not.toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Organization Features', () => {
|
||||
test('Scenario: Pinned note appears under the Pinned section', async ({ authenticatedPage: page }) => {
|
||||
@@ -8,8 +8,9 @@ test.describe('Organization Features', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user pins the note
|
||||
await page.getByTestId('note-card').hover();
|
||||
await page.getByTestId('pin-btn').click();
|
||||
const pinCard = noteCard(page, 'Pin Me');
|
||||
await pinCard.hover();
|
||||
await pinCard.getByTestId('pin-btn').click();
|
||||
|
||||
// Then the "Pinned" section is visible
|
||||
await expect(page.getByText('Pinned')).toBeVisible();
|
||||
@@ -22,8 +23,9 @@ test.describe('Organization Features', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user archives the note
|
||||
await page.getByTestId('note-card').hover();
|
||||
await page.getByTestId('archive-btn').click();
|
||||
const archiveCard = noteCard(page, 'Archive Me');
|
||||
await archiveCard.hover();
|
||||
await archiveCard.getByTestId('archive-btn').click();
|
||||
|
||||
// Then the note is no longer visible in the main view
|
||||
await expect(page.getByText('Archive Me')).not.toBeVisible();
|
||||
@@ -40,7 +42,7 @@ test.describe('Organization Features', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// Then the note is saved and visible in the notes list
|
||||
await expect(page.getByTestId('note-card')).toBeVisible();
|
||||
await expect(noteCard(page, 'Colored Note')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Scenario: Filtering by tag shows only matching notes', async ({ authenticatedPage: page }) => {
|
||||
@@ -57,7 +59,7 @@ test.describe('Organization Features', () => {
|
||||
await page.getByTestId('close-editor-btn').click();
|
||||
|
||||
// When the user filters by the #important tag
|
||||
const tagChip = page.getByTestId('tag-chip').filter({ hasText: '#important' });
|
||||
const tagChip = page.getByTestId('tag-filter').getByTestId('tag-chip').filter({ hasText: '#important' });
|
||||
if (await tagChip.isVisible()) {
|
||||
await tagChip.click();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from './helpers/fixtures.js';
|
||||
import { test, expect, noteCard } from './helpers/fixtures.js';
|
||||
|
||||
test.describe('Search', () => {
|
||||
test('Scenario: Searching by title returns the matching note', async ({ authenticatedPage: page }) => {
|
||||
@@ -42,6 +42,6 @@ test.describe('Search', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Then no notes are displayed
|
||||
await expect(page.getByTestId('note-card')).not.toBeVisible();
|
||||
await expect(page.getByTestId('note-card')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user