fix(udgaver): porten dækkede 29 ruter og skulle dække 63 — og fladen ringede stadig på dørene
Prøven, der skal holde udgaverne adskilt, stod grøn gennem to merges, mens
34 opgaveruter blev betjent af noter-udgaven. Den fejlede ikke: den
sammenligner routeren med en nedskreven liste, og listen var skrevet ud
fra de ruter, nogen havde tænkt på. Den er en DRIFT-kontrol, og den kan
ikke være en fuldstændighedskontrol. Det står nu i prøven selv.
Lukket: CalDAV-træet (8 ruter, alle læser eller skriver en VTODO),
.well-known/caldav (en påstand om at der er en CalDAV-server), ICS-feedet
og de to ruter til dets token, de fire opgave-eksporter, Todoist- og
CSV-importen, skabelonerne, sektionerne, de seks AI-opgaveruter og
indbakke-krogen. Plus fem af ni MCP-værktøjer — endepunktet er der
stadig, værktøjslisten er kortere.
Tre blev afgjort ved at læse frem for ved navnet: en skabelon laver
opgaver (CreateProjectFromTemplate), `section_id` står på `tasks` og
findes ikke på `notes`, og add_comment hænger på en opgave.
Men den dyre halvdel var fladen. En vandring gennem hver side, der kan
nås i noter-udgaven, med konsollen og svarene aflæst, fandt SYV brud —
fire af dem fra den første port:
sidebjælken GET /filters 404 ved hver navigation, ædt af .catch()
sidebjælken GET /labels 404 ved hver navigation, ædt af .catch()
notifikationer GET /ai/plan 404, stille
notesbogens side GET /tasks kastede → status = 'failed'
notesbogens side GET /sections kastede → samme fejlskærm
indstillinger GET /templates toast med fejlbesked på skærmen
integrationer GET /feed 404, stille
Den fjerde er den, der har kostet noget: /projekt/{id} er notesbogens
side, og kaldene samles i et Promise.all, så én 404 tog hele siden med.
Den har vist en fejlskærm frem for noter siden porten blev sat. Ingen
prøve åbnede en notesbog i den udgave, og .catch(() => {}) sørgede for,
at resten ikke sagde noget. "Fladen følger udgaven" var sandt om menuen
og falsk om hentningerne.
Vagten for opgaver står i butikken frem for ved kaldestederne: der var
tre, og den fjerde blev glemt — hvilket er præcis sådan, den slap
igennem.
Og rettelsen lavede selv en fejl, som vandringen fangede: da opgavekaldet
holdt op med at fejle, kom en uendelig effekt-løkke frem, som 404'eren
havde holdt låg på. En ubetinget tilskrivning til app.tasks får
projektsidens effekt til at kalde tilbage. 404'eren var låget på en
løkke, ikke bare støj.
Også: -timeout 20m i begge CI-workflows. internal/httpapi tog 664s her og
498s i CI på #95, mod Go's standardgrænse på 600 — og når den krydses, er
beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, som
læser som en deadlock i koden frem for som en suite, der blev for stor.
watchForTrouble udskilt til web/e2e/trouble.js, så udgave-suiten kan bruge
den samme.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c6c7979d1a
commit
8c3aa33f13
@@ -97,7 +97,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
@@ -40,7 +40,13 @@ jobs:
|
||||
# -race is the whole point of running tests in CI for a server with a
|
||||
# WebSocket fan-out: a data race is exactly the bug that passes locally.
|
||||
- name: Test
|
||||
run: go test -race -count=1 ./...
|
||||
# -timeout, fordi internal/httpapi nærmer sig Go's standardgrænse på 600s:
|
||||
# 498s i CI på #95, 656s på udviklingsmaskinen. Når den krydser, er
|
||||
# beskeden "panic: test timed out after 10m0s" plus et goroutine-dump, og
|
||||
# det læser som en deadlock i koden frem for som en suite, der blev for
|
||||
# stor. Grænsen er der stadig; den er bare sat et sted, hvor den betyder
|
||||
# "noget hænger" i stedet for "maskinen var langsom i dag".
|
||||
run: go test -race -count=1 -timeout 20m ./...
|
||||
|
||||
- name: Build
|
||||
run: CGO_ENABLED=0 go build ./...
|
||||
|
||||
@@ -28,7 +28,8 @@ wood. Verdande is what is becoming; urd is what has become and was kept.
|
||||
Not a fork, because the two share everything that is hard — accounts, sharing,
|
||||
passkeys, the realtime socket, search, backups, migrations — and notes are about a
|
||||
twentieth of the Go here. A test walks both routers and fails in either direction,
|
||||
so "the task routes are not there" is measured rather than promised. See
|
||||
so "the task routes are not there" is measured rather than promised — 63 routes and
|
||||
five of nine MCP tools. See
|
||||
[Two editions](docs/editions.md).
|
||||
|
||||
## Status
|
||||
|
||||
+35
-6
@@ -6,7 +6,7 @@ One program, built once, with two faces.
|
||||
|---|---|---|
|
||||
| What it is | Tasks, projects and notes | Notes |
|
||||
| `VERDANDE_EDITION` | `full` (the default) | `notes` |
|
||||
| Routes served | 200 | 171 |
|
||||
| Routes served | 200 | 137 |
|
||||
| Mark | ᚹ | ᚢ |
|
||||
|
||||
Set `VERDANDE_EDITION=notes` and the instance is urd: the task routes are not
|
||||
@@ -24,7 +24,7 @@ needed applying twice, by hand, forever.
|
||||
|
||||
So the split is a configuration value read in one place, and the thing that keeps
|
||||
it honest is a test rather than a convention: `TestNotesEditionServesOnlyItsOwnRoutes`
|
||||
walks both routers, compares them against a written-down list of the 29 withheld
|
||||
walks both routers, compares them against a written-down list of the 63 withheld
|
||||
routes, and fails in **both** directions — a route that appears in the notes
|
||||
edition and a route that stops being withheld are both a failure.
|
||||
|
||||
@@ -39,16 +39,45 @@ with no error anywhere and no way to tell from the outside.
|
||||
|
||||
## What urd does not have
|
||||
|
||||
The 29 withheld routes are tasks, sub-tasks, labels, saved filters, reminders, the
|
||||
upcoming and delegated views, and the task half of search and export. Everything
|
||||
else is the same program:
|
||||
The 63 withheld routes:
|
||||
|
||||
| | what |
|
||||
|---|---|
|
||||
| 29 | tasks, sub-tasks, labels, saved filters, reminders, the upcoming and delegated views |
|
||||
| 8 | the CalDAV server — every route in it reads or writes a VTODO |
|
||||
| 1 | `/.well-known/caldav`, which is a claim that a CalDAV server is here |
|
||||
| 1 | the ICS calendar feed, plus 2 for the token it is reached with |
|
||||
| 4 | the task exports: `tasks.ics`, `projects.zip`, and a project's `.csv` and `.ics` |
|
||||
| 2 | the Todoist and CSV imports, which create tasks |
|
||||
| 4 | project templates — a template is a project plus sections plus tasks |
|
||||
| 5 | sections, which group tasks: `section_id` is a column on `tasks` and not on `notes` |
|
||||
| 6 | the AI task features: split, tidy the inbox, and the day's plan |
|
||||
| 1 | the inbox hook, which pushes a line of text in as a task |
|
||||
|
||||
Plus five of the nine MCP tools — `search_tasks`, `create_task`, `update_task`,
|
||||
`complete_task` and `add_comment`. The endpoint is still there; the tool list is
|
||||
shorter. A tool a model can call is a surface, and one that answers "there are no
|
||||
tasks" is worse than one that does not exist: the model tries it, gets an empty
|
||||
answer, and tells the person there is nothing to do.
|
||||
|
||||
Everything else is the same program:
|
||||
|
||||
- accounts, invitations, passkeys and two-factor
|
||||
- projects — which in urd are what a note belongs to
|
||||
- per-note sharing with other people, independent of projects
|
||||
- search, over notes
|
||||
- export, of notes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes
|
||||
- the mail, calendar and MCP surfaces, minus their task routes and task tools
|
||||
- AI settings, `ask`, and the note actions
|
||||
|
||||
!!! warning "Still being decided"
|
||||
Four connectors are reachable in urd today and have not been settled either
|
||||
way, because each has a plausible notes reading and withholding them would be
|
||||
deciding what this product is rather than closing a hole: the mail-in address,
|
||||
IMAP mailboxes, Gmail, and Google Calendar. They currently file what they
|
||||
receive as **tasks**, so in urd they connect and then have nowhere to put
|
||||
anything. The same applies to one handler rather than a route:
|
||||
`POST /api/v1/ai/notes/{noteID}/actions/apply` creates tasks from a note.
|
||||
|
||||
## The name
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -36,28 +37,69 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
// Every route the full edition has and the notes edition does not. Written as
|
||||
// the whole list rather than a prefix rule: a prefix is a description of what
|
||||
// somebody meant, and this has to be a description of what is there.
|
||||
//
|
||||
// And note what this list could NOT catch, because it is the shape of mistake
|
||||
// that outlived the first version of this test. The list went green at 29
|
||||
// routes for two merges while CalDAV, the ICS feed, the inbox hook, four task
|
||||
// exports, both task imports, the project templates, the sections and six AI
|
||||
// task endpoints were all still served by the notes edition — because the test
|
||||
// compares the router against THIS LIST, and the list was written from the
|
||||
// routes somebody had thought of. It is a drift check, and it cannot be an
|
||||
// is-it-complete check.
|
||||
//
|
||||
// So when a route is added that one edition should not have, the question to
|
||||
// ask is not "does the walk still pass" but "can this endpoint do anything at
|
||||
// all without the half it is being withheld from". Three of the ones above were
|
||||
// settled by reading rather than by the name: a template turned out to create
|
||||
// tasks (CreateProjectFromTemplate), `section_id` turned out to exist on
|
||||
// `tasks` and not on `notes`, and add_comment turned out to hang off a task.
|
||||
withheld := map[string]bool{}
|
||||
for _, r := range []string{
|
||||
"DELETE /api/v1/filters/{filterID}",
|
||||
"DELETE /api/v1/labels/{labelID}",
|
||||
"DELETE /api/v1/reminders/{reminderID}",
|
||||
"DELETE /api/v1/sections/{sectionID}",
|
||||
"DELETE /api/v1/tasks/{taskID}",
|
||||
"DELETE /api/v1/templates/{templateID}",
|
||||
"DELETE /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /.well-known/caldav",
|
||||
"GET /api/v1/ai/plan",
|
||||
"GET /api/v1/delegated",
|
||||
"GET /api/v1/export/projects.zip",
|
||||
"GET /api/v1/export/projects/{projectID}.csv",
|
||||
"GET /api/v1/export/projects/{projectID}.ics",
|
||||
"GET /api/v1/export/tasks.ics",
|
||||
"GET /api/v1/feed",
|
||||
"GET /api/v1/filters",
|
||||
"GET /api/v1/filters/preview",
|
||||
"GET /api/v1/filters/{filterID}/tasks",
|
||||
"GET /api/v1/labels",
|
||||
"GET /api/v1/projects/{projectID}/sections",
|
||||
"GET /api/v1/tasks",
|
||||
"GET /api/v1/tasks/quick-add/preview",
|
||||
"GET /api/v1/tasks/{taskID}",
|
||||
"GET /api/v1/tasks/{taskID}/comments",
|
||||
"GET /api/v1/tasks/{taskID}/reminders",
|
||||
"GET /api/v1/templates",
|
||||
"GET /api/v1/upcoming",
|
||||
"GET /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"GET /ics/{token}",
|
||||
"OPTIONS /caldav/*",
|
||||
"PATCH /api/v1/filters/{filterID}",
|
||||
"PATCH /api/v1/labels/{labelID}",
|
||||
"PATCH /api/v1/sections/{sectionID}",
|
||||
"PATCH /api/v1/tasks/{taskID}",
|
||||
"POST /api/v1/ai/inbox/tidy",
|
||||
"POST /api/v1/ai/inbox/tidy/apply",
|
||||
"POST /api/v1/ai/plan/now",
|
||||
"POST /api/v1/ai/tasks/{taskID}/split",
|
||||
"POST /api/v1/feed/rotate",
|
||||
"POST /api/v1/filters",
|
||||
"POST /api/v1/import/csv",
|
||||
"POST /api/v1/import/todoist",
|
||||
"POST /api/v1/labels",
|
||||
"POST /api/v1/projects/{projectID}/sections",
|
||||
"POST /api/v1/projects/{projectID}/sections/reorder",
|
||||
"POST /api/v1/tasks",
|
||||
"POST /api/v1/tasks/quick-add",
|
||||
"POST /api/v1/tasks/{taskID}/attachments",
|
||||
@@ -67,6 +109,15 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
"POST /api/v1/tasks/{taskID}/reminders",
|
||||
"POST /api/v1/tasks/{taskID}/reopen",
|
||||
"POST /api/v1/tasks/{taskID}/snooze",
|
||||
"POST /api/v1/templates",
|
||||
"POST /api/v1/templates/{templateID}/use",
|
||||
"POST /inbound/hook/{token}",
|
||||
"PROPFIND /caldav",
|
||||
"PROPFIND /caldav/{userID}",
|
||||
"PROPFIND /caldav/{userID}/{projectID}",
|
||||
"PUT /api/v1/ai/plan",
|
||||
"PUT /caldav/{userID}/{projectID}/{taskFile}",
|
||||
"REPORT /caldav/{userID}/{projectID}",
|
||||
} {
|
||||
withheld[r] = true
|
||||
}
|
||||
@@ -115,10 +166,25 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||||
for _, must := range []string{
|
||||
"GET /api/v1/notes",
|
||||
"POST /api/v1/notes",
|
||||
"PATCH /api/v1/notes/{noteID}",
|
||||
"DELETE /api/v1/notes/{noteID}",
|
||||
// Deling er hele grunden til at det er et selvstændigt produkt frem for en
|
||||
// enkeltbrugers notesblok, så den halvdel skal navngives her.
|
||||
"POST /api/v1/notes/{noteID}/shares",
|
||||
"GET /api/v1/notes/{noteID}/shares",
|
||||
"POST /api/v1/notes/{noteID}/attachments",
|
||||
"POST /api/v1/notes/import",
|
||||
"GET /api/v1/export/notes.zip",
|
||||
"GET /api/v1/export/account",
|
||||
"POST /api/v1/ai/notes/{noteID}/actions",
|
||||
"GET /api/v1/projects", // a notebook, in this edition
|
||||
"GET /api/v1/search",
|
||||
"GET /api/v1/auth/me",
|
||||
// 34 ruter blev lukket i den anden omgang af denne port. Den her linje er
|
||||
// den, der afgør, at det var en port: MCP-endepunktet er stadig der, og det
|
||||
// er værktøjslisten bagved, der er kortere.
|
||||
"POST /api/v1/mcp",
|
||||
"POST /mcp",
|
||||
} {
|
||||
if !notes[must] {
|
||||
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||||
@@ -228,3 +294,82 @@ func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotesEditionOffersNoTaskToolsOverMCP.
|
||||
//
|
||||
// The same hole as the routes, through a different door — and it was still open
|
||||
// after the first gate. An MCP server advertises its tools, so a model connected
|
||||
// to a notes instance would be told it can create_task, call it, get something
|
||||
// back, and tell the person their task was filed.
|
||||
//
|
||||
// Asked through tools/list rather than by reading the server's own slice: that is
|
||||
// what a client actually sees, and the slice is unexported for the same reason.
|
||||
func TestNotesEditionOffersNoTaskToolsOverMCP(t *testing.T) {
|
||||
taskTools := []string{"search_tasks", "create_task", "update_task", "complete_task", "add_comment"}
|
||||
noteTools := []string{"search_notes", "create_note", "update_note"}
|
||||
|
||||
for _, edition := range []string{config.EditionFull, config.EditionNotes} {
|
||||
t.Run(edition, func(t *testing.T) {
|
||||
names := mcpToolNames(t, edition)
|
||||
|
||||
// Both editions: the shared tool, and the notes half.
|
||||
for _, want := range append([]string{"list_projects"}, noteTools...) {
|
||||
if !names[want] {
|
||||
t.Errorf("%s edition does not offer %s", edition, want)
|
||||
}
|
||||
}
|
||||
for _, task := range taskTools {
|
||||
switch edition {
|
||||
case config.EditionFull:
|
||||
if !names[task] {
|
||||
t.Errorf("the full edition lost %s — that is a deletion, not a gate", task)
|
||||
}
|
||||
default:
|
||||
if names[task] {
|
||||
t.Errorf("the notes edition offers %s over MCP", task)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d tools", edition, len(names))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mcpToolNames asks one edition's MCP server for its tool list.
|
||||
func mcpToolNames(t *testing.T, edition string) map[string]bool {
|
||||
t.Helper()
|
||||
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{BaseURL: "http://localhost", DataDir: t.TempDir(), SessionTTL: time.Hour, Edition: edition}
|
||||
srv := New(cfg, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||||
|
||||
resp := srv.mcp.Handle(context.Background(), "nobody", []byte(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
|
||||
if resp == nil {
|
||||
t.Fatal("tools/list got no response")
|
||||
}
|
||||
raw, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Result struct {
|
||||
Tools []struct{ Name string } `json:"tools"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
t.Fatalf("tools/list: %v — %s", err, raw)
|
||||
}
|
||||
if len(parsed.Result.Tools) == 0 {
|
||||
t.Fatalf("tools/list returned no tools at all: %s", raw)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, tool := range parsed.Result.Tools {
|
||||
names[tool.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ import (
|
||||
// `search_tasks` and `list_projects` is answering "which of these gets me closer",
|
||||
// and a description that only names the endpoint gives it nothing to decide with.
|
||||
func (s *Server) buildMCP() *mcp.Server {
|
||||
m := mcp.NewServer("verdande", "1")
|
||||
// Udgavens eget navn: det er sådan, instansen står i klientens
|
||||
// forbindelsesliste.
|
||||
m := mcp.NewServer(s.cfg.ProductSlug(), "1")
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "list_projects",
|
||||
@@ -34,72 +36,79 @@ func (s *Server) buildMCP() *mcp.Server {
|
||||
}),
|
||||
}, s.mcpListProjects)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
// De fem opgaveværktøjer. Udeladt i noter-udgaven af samme grund som ruterne:
|
||||
// et værktøj, en model kan kalde, er en flade på instansen, og et værktøj der
|
||||
// svarer "der er ingen opgaver" er værre end et, der ikke findes — modellen
|
||||
// prøver det, får et tomt svar og fortæller brugeren, at der ikke er noget at
|
||||
// lave. add_comment er med her: en kommentar hænger på en opgave.
|
||||
if !s.cfg.NotesOnly() {
|
||||
m.Register(mcp.Tool{
|
||||
Name: "search_tasks",
|
||||
Description: "Find tasks by text, project, label, priority, due date or assignee. " +
|
||||
"This is the tool for any question about what the user has to do — " +
|
||||
"\"what is due today\", \"what is overdue\", \"anything about the tax return\". " +
|
||||
"The query accepts the same filter language as the app: \"today & p1\", " +
|
||||
"\"overdue\", \"#Firma & @regnskab\", \"7 days\", \"assigned to: me\".",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"query": mcp.Str("A filter expression, e.g. \"today & p1\" or \"overdue\"."),
|
||||
"text": mcp.Str("Free text to search task titles and descriptions for."),
|
||||
"limit": mcp.Int("How many tasks to return. Defaults to 50, maximum 200."),
|
||||
"include_completed": mcp.Bool("Include tasks that are already done. Defaults to false."),
|
||||
}),
|
||||
}, s.mcpSearchTasks)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "create_task",
|
||||
Description: "Create a task. Prefer passing the whole thing as natural language in " +
|
||||
"`text` — \"betal moms i morgen kl 10 p1 #Firma @regnskab\" — because the app's own " +
|
||||
"parser handles dates, times, priorities, projects, labels and recurrence in Danish " +
|
||||
"and English. Use the explicit fields only when the user was explicit.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"text": mcp.Str("The task as a sentence, parsed for date, time, priority, #project, @label and recurrence."),
|
||||
"content": mcp.Str("The task title, if not using `text`."),
|
||||
"project_id": mcp.Str("Which project. Defaults to the Inbox. Naming a project that does not exist is not an error — the task goes to the Inbox and the answer says so."),
|
||||
"parent_id": mcp.Str("Make this a sub-task of that task. It joins the parent's project. One level deep."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"description": mcp.Str("Longer notes on the task."),
|
||||
"labels": mcp.StrArray("Label names."),
|
||||
}),
|
||||
}, s.mcpCreateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "update_task",
|
||||
Description: "Change an existing task: its title, description, priority, due date or " +
|
||||
"project. Only the fields given are changed. To move a due date, pass due_date; " +
|
||||
"to clear it, pass an empty string.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to change."),
|
||||
"content": mcp.Str("A new title."),
|
||||
"description": mcp.Str("New notes."),
|
||||
"priority": mcp.Int("1 (highest) to 4 (none)."),
|
||||
"due_date": mcp.Str("A date as YYYY-MM-DD, or an empty string to remove the date."),
|
||||
"project_id": mcp.Str("Move the task to another project."),
|
||||
"labels": mcp.StrArray("Replace the task's labels."),
|
||||
}, "task_id"),
|
||||
}, s.mcpUpdateTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "complete_task",
|
||||
Description: "Mark a task done. A repeating task advances to its next occurrence " +
|
||||
"instead of closing, and the result says so.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to complete."),
|
||||
}, "task_id"),
|
||||
}, s.mcpCompleteTask)
|
||||
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
m.Register(mcp.Tool{
|
||||
Name: "add_comment",
|
||||
Description: "Add a comment to a task. Everyone else with access to the project is notified.",
|
||||
InputSchema: mcp.Schema(map[string]any{
|
||||
"task_id": mcp.Str("The task to comment on."),
|
||||
"body": mcp.Str("What to say."),
|
||||
}, "task_id", "body"),
|
||||
}, s.mcpAddComment)
|
||||
}
|
||||
|
||||
s.registerNoteTools(m)
|
||||
return m
|
||||
|
||||
+100
-39
@@ -159,13 +159,31 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Docker both need this to answer before anyone has logged in.
|
||||
r.Get("/healthz", s.handleHealth)
|
||||
|
||||
// The same question as inside /api/v1, asked again here because four of the
|
||||
// task-only surfaces live outside it: the calendar feed, CalDAV, the inbox
|
||||
// hook, and the well-known path CalDAV clients discover through. They are out
|
||||
// here because their callers cannot hold a session, not because they are any
|
||||
// less about tasks — and the first gate missed all four for exactly that
|
||||
// reason. It read as complete because the /tasks tree was covered.
|
||||
full := !s.cfg.NotesOnly()
|
||||
|
||||
// The calendar feed is outside /api/v1 and outside the session: a calendar
|
||||
// client cannot log in, so the token in the path is the whole credential.
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
//
|
||||
// A feed of VTODOs, so there is nothing for it to answer in the notes edition.
|
||||
if full {
|
||||
r.Get("/ics/{token}", s.handleICSFeed)
|
||||
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
// CalDAV, outside /api/v1 because clients do well-known-path discovery
|
||||
// against the root and authenticate with Basic rather than a session.
|
||||
//
|
||||
// Withheld rather than left to answer an empty collection: the well-known
|
||||
// path is a claim that a CalDAV server is here, and a client that believes
|
||||
// it goes on to PROPFIND, authenticate, and sync nothing — reporting a
|
||||
// broken calendar server on an instance that has none and needs none. Same
|
||||
// reasoning as the /.well-known/ 404 in serveWeb.
|
||||
r.Get("/.well-known/caldav", s.wellKnownCalDAV)
|
||||
}
|
||||
|
||||
// Inbound mail. Delivered by the mail server rather than by a browser, and
|
||||
// authenticated by the token in the recipient address.
|
||||
@@ -190,8 +208,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
//
|
||||
// Sin egen kvote frem for postens: en travl integration skal ikke kunne lukke
|
||||
// for den indgående post, og omvendt.
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
//
|
||||
// Opgaver, efter sin egen beskrivelse ovenfor — den skubber en linje tekst ind
|
||||
// i indbakken — så den findes ikke i noter-udgaven.
|
||||
if full {
|
||||
r.Method(http.MethodPost, "/inbound/hook/{token}",
|
||||
s.rateLimit(s.hookLimiter, http.HandlerFunc(s.handleInboundHook)))
|
||||
}
|
||||
|
||||
// MCP with the token in the query string, for clients that cannot send a
|
||||
// header. Claude's custom-connector dialog takes a URL and nothing else, so
|
||||
@@ -218,7 +241,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
// Gmail connection with calendar tokens.
|
||||
r.Get("/oauth/calendar/callback", s.handleCalendarCallback)
|
||||
})
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
// Hele CalDAV-træet: otte ruter, der alle læser eller skriver en VTODO.
|
||||
if full {
|
||||
r.Route("/caldav", s.caldavRoutes)
|
||||
}
|
||||
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
r.Use(s.requireCSRF)
|
||||
@@ -346,7 +372,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
// Tokenet til ICS-feedet ovenfor. Et token til et feed, der ikke
|
||||
// findes, er en indstillingsside, der tilbyder en adresse, intet
|
||||
// svarer på.
|
||||
if full {
|
||||
r.Get("/feed", s.handleGetFeed)
|
||||
}
|
||||
|
||||
r.Route("/notifications", func(r chi.Router) {
|
||||
r.Get("/", s.handleListNotifications)
|
||||
@@ -372,7 +403,9 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Patch("/", s.handleUpdateComment)
|
||||
r.Delete("/", s.handleDeleteComment)
|
||||
})
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
if full {
|
||||
r.Post("/feed/rotate", s.handleRotateFeed)
|
||||
}
|
||||
|
||||
r.Get("/mail-address", s.handleGetMailAddress)
|
||||
r.Post("/mail-address/rotate", s.handleRotateMailAddress)
|
||||
@@ -393,20 +426,24 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/settings", s.handleGetAISettings)
|
||||
r.Put("/settings", s.handleSetAISettings)
|
||||
r.Post("/summary", s.handleAISummary)
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
if full {
|
||||
r.Post("/tasks/{taskID}/split", s.handleAISplit)
|
||||
// Forslag, ikke skrivninger. De to første svarer med linjer i
|
||||
// quick-add-syntaks, som fladen viser frem; de to sidste er dét,
|
||||
// der sker, når nogen siger ja til én af dem.
|
||||
r.Post("/inbox/tidy", s.handleAITidyInbox)
|
||||
r.Post("/inbox/tidy/apply", s.handleAIApplyToTask)
|
||||
}
|
||||
r.Post("/notes/{noteID}/actions", s.handleAINoteActions)
|
||||
r.Post("/notes/{noteID}/actions/apply", s.handleAICreateFromNote)
|
||||
// Dagens plan: indstillingen, og en knap til at se den nu. En
|
||||
// indstilling, man skal vente til i morgen for at se virkningen af,
|
||||
// er en indstilling, ingen tør slå til.
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
if full {
|
||||
r.Get("/plan", s.handleGetPlanSettings)
|
||||
r.Put("/plan", s.handleSetPlanSettings)
|
||||
r.Post("/plan/now", s.handlePlanNow)
|
||||
}
|
||||
// Spørg om sine egne noter og opgaver i almindeligt sprog. Basen
|
||||
// finder kandidaterne, modellen læser dem — og svaret bærer, hvad
|
||||
// det er bygget på.
|
||||
@@ -491,24 +528,38 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Delete("/subscriptions/{id}", s.handleUnsubscribeCalendar)
|
||||
})
|
||||
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
// Begge retninger laver eller læser opgaver: Todoist-importen laver
|
||||
// dem, CSV-importen laver dem, og de fire opgave-eksporter skriver dem
|
||||
// ud. De blev alle betjent i noter-udgaven indtil nu — de svarede tomt
|
||||
// frem for at fejle, hvilket er den stille version af samme fejl.
|
||||
if full {
|
||||
r.Route("/import", func(r chi.Router) {
|
||||
r.Post("/todoist", s.handleImportTodoist)
|
||||
r.Post("/csv", s.handleImportCSV)
|
||||
})
|
||||
}
|
||||
|
||||
r.Get("/export/account", s.handleExportAccount)
|
||||
r.Get("/export/notes.zip", s.handleExportNotes)
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
if full {
|
||||
r.Get("/export/projects.zip", s.handleExportAllCSV)
|
||||
r.Get("/export/tasks.ics", s.handleExportAllICS)
|
||||
r.Get("/export/projects/{projectID}.csv", s.handleExportProject)
|
||||
r.Get("/export/projects/{projectID}.ics", s.handleExportProjectICS)
|
||||
}
|
||||
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
// En skabelon er et projekt plus sektioner plus opgaver — læst i
|
||||
// CreateProjectFromTemplate frem for gættet ud fra rutenavnet, som
|
||||
// kun siger "projekt". Uden opgaver er den et tomt projekt med
|
||||
// overskrifter i.
|
||||
if full {
|
||||
r.Route("/templates", func(r chi.Router) {
|
||||
r.Get("/", s.handleListTemplates)
|
||||
r.Post("/", s.handleSaveTemplate)
|
||||
r.Post("/{templateID}/use", s.handleUseTemplate)
|
||||
r.Delete("/{templateID}", s.handleDeleteTemplate)
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Delete("/reminders/{reminderID}", s.handleDeleteReminder)
|
||||
@@ -539,7 +590,13 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireProject(store.RoleViewer))
|
||||
|
||||
r.Get("/", s.handleGetProject)
|
||||
r.Get("/sections", s.handleListSections)
|
||||
// Sektioner grupperer opgaver inde i et projekt, og det er
|
||||
// afgjort af skemaet frem for af navnet: `section_id` står på
|
||||
// `tasks` og findes ikke på `notes`. En sektion i en notesbog
|
||||
// kan ikke indeholde noget.
|
||||
if full {
|
||||
r.Get("/sections", s.handleListSections)
|
||||
}
|
||||
r.Get("/members", s.handleListMembers)
|
||||
// Uden for ejer-gruppen, fordi den nu dækker to ting: ejeren, der
|
||||
// fjerner nogen, og den, der selv går. Handleren skiller dem ad.
|
||||
@@ -548,8 +605,10 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireProject(store.RoleEditor))
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
if full {
|
||||
r.Post("/sections", s.handleCreateSection)
|
||||
r.Post("/sections/reorder", s.handleReorderSections)
|
||||
}
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -624,10 +683,12 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Get("/trash/projects", s.handleListTrashedProjects)
|
||||
r.Post("/trash/projects/{projectID}/restore", s.handleRestoreProject)
|
||||
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
if full {
|
||||
r.Route("/sections/{sectionID}", func(r chi.Router) {
|
||||
r.Patch("/", s.handleUpdateSection)
|
||||
r.Delete("/", s.handleDeleteSection)
|
||||
})
|
||||
}
|
||||
|
||||
if full {
|
||||
r.Route("/tasks", func(r chi.Router) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* Noter-udgaven, kørt som sit eget program.
|
||||
@@ -179,3 +180,69 @@ test('navnet står også i teksterne, ikke kun i mærket', async ({ page }) => {
|
||||
await expect(page.getByRole('button', { name: /^Urd\b/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Verdande\b/ })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Hver side, der KAN nås i noter-udgaven, må ikke kalde noget, der er lukket.
|
||||
*
|
||||
* Det er den halvdel, en port let glemmer, og den fejler stille: lukkes en rute,
|
||||
* fladen stadig henter, svarer serveren 404, komponenten fanger det, og siden
|
||||
* tegner uden den sektion — pænt, tomt, og uden at nogen får at vide, at noget
|
||||
* manglede. Der er ingen fejlskærm at se.
|
||||
*
|
||||
* Målt på svarene frem for på det tegnede: en manglende sektion ser ud som en
|
||||
* sektion, der ikke var noget i.
|
||||
*
|
||||
* Skrevet før ruterne blev lukket, og den fangede SYV — fire af dem fra den første
|
||||
* port, som havde været i drift i to merges: sidebjælken hentede /filters og
|
||||
* /labels ved hver navigation, notifikationssiden hentede /ai/plan, og
|
||||
* notesbogens egen side hentede /tasks. Den sidste var ikke støj: kaldene samles i
|
||||
* et Promise.all, så 404'eren kastede og siden viste `status = 'failed'` frem for
|
||||
* notesbogens noter.
|
||||
*
|
||||
* Og den fangede én mere, som rettelsen selv lavede: da opgavekaldet holdt op med
|
||||
* at fejle, kom en uendelig effekt-løkke frem, som 404'eren havde holdt låg på
|
||||
* (effect_update_depth_exceeded). Derfor læser den konsollen og ikke kun svarene.
|
||||
*
|
||||
* Hvad den IKKE dækker, sagt her frem for underforstået: elleve sider plus ét
|
||||
* projekt. En gruppe (/gruppe/{id}) og en åben note er ikke med, fordi begge
|
||||
* kræver, at prøven først laver en — og skuffer og dialoger, der åbnes ved et
|
||||
* klik, er slet ikke besøgt. En grøn her betyder "ingen af de tolv adresser",
|
||||
* ikke "ingen flade".
|
||||
*/
|
||||
test('ingen side kalder en rute, udgaven ikke har', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
// Projektet skal med som en rigtig adresse: /projekt/{id} er en notesbog her,
|
||||
// og den er den eneste side med en id i stien, der kan nås.
|
||||
await page.goto('/noter');
|
||||
const projekter = await (await page.request.get('/api/v1/projects')).json();
|
||||
const id = (projekter.projects ?? projekter)[0]?.id;
|
||||
expect(id, 'der skal findes mindst ét projekt at åbne').toBeTruthy();
|
||||
|
||||
const sider = [
|
||||
'/noter',
|
||||
'/genveje',
|
||||
'/indstillinger',
|
||||
'/indstillinger/ai',
|
||||
'/indstillinger/brugere',
|
||||
'/indstillinger/data',
|
||||
'/indstillinger/fejl',
|
||||
'/indstillinger/historik',
|
||||
'/indstillinger/integrationer',
|
||||
'/indstillinger/notifikationer',
|
||||
'/indstillinger/tokens',
|
||||
`/projekt/${id}`
|
||||
];
|
||||
|
||||
for (const side of sider) {
|
||||
await page.goto(side);
|
||||
// Ventet på at fladen er tegnet, så de kald siden laver ved opstart er
|
||||
// nået at komme afsted. Uden den her går løkken videre, før svarene er
|
||||
// kommet, og prøven måler ingenting.
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Tallet med på det grønne: elleve sider og et projekt, frem for "ingen fejl".
|
||||
expect(trouble, `besøgte ${sider.length} sider`).toEqual([]);
|
||||
});
|
||||
|
||||
+1
-34
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { USER } from './user.js';
|
||||
import { watchForTrouble } from './trouble.js';
|
||||
|
||||
/**
|
||||
* The four flows a broken build must not survive: add a task, tick it off, reach
|
||||
@@ -11,14 +12,6 @@ import { USER } from './user.js';
|
||||
* precisely the bug this suite exists to catch.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*/
|
||||
/**
|
||||
* Opens the project header's overflow menu and clicks one of its items.
|
||||
*
|
||||
@@ -93,32 +86,6 @@ async function nyNote(side) {
|
||||
return felt;
|
||||
}
|
||||
|
||||
function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Collects anything that went wrong quietly: a thrown exception, a console error,
|
||||
* or an API call that came back a failure.
|
||||
*
|
||||
* Failed responses are recorded with their URL and status. "A console error
|
||||
* happened" is not an actionable test failure — "PATCH /api/v1/tasks/x returned
|
||||
* 500" is.
|
||||
*
|
||||
* Udskilt fra smoke.spec.js, så udgave-suiten kan bruge den samme — og den
|
||||
* fortjente flytningen med det samme: lukker man en rute, fladen stadig kalder,
|
||||
* står der en 404 på en side, der ser helt rigtig ud, og det her er det eneste,
|
||||
* der ser den.
|
||||
*/
|
||||
export function watchForTrouble(page) {
|
||||
const trouble = [];
|
||||
page.on('console', (message) => {
|
||||
// The browser logs its own line for every failed fetch. It carries no URL,
|
||||
// so it is noise next to the response listener below.
|
||||
if (message.type() === 'error' && !message.text().includes('Failed to load resource')) {
|
||||
trouble.push(message.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
@@ -73,6 +73,11 @@
|
||||
// through the connector.
|
||||
$effect(() => {
|
||||
app.labelsChanged;
|
||||
// Ikke i noter-udgaven: begge ruter findes slet ikke der, så de to kald var
|
||||
// to 404'ere ved hver navigation — opslugt af .catch() og derfor usynlige.
|
||||
// Fundet af vandringen i edition.spec.js, efter at porten havde været i
|
||||
// drift i to merges.
|
||||
if (app.notesOnly) return;
|
||||
api.listFilters().then((r) => (filters = r.filters)).catch(() => {});
|
||||
api.listLabels().then((r) => (labels = r.labels)).catch(() => {});
|
||||
});
|
||||
|
||||
@@ -343,6 +343,21 @@ class AppState {
|
||||
* `completed: 'exclude'`.
|
||||
*/
|
||||
async loadTasks(params) {
|
||||
// Noter-udgaven har ingen opgaveruter, så den her ville kaste en 404 — og
|
||||
// kalderne samler den i et Promise.all, hvor én fejl tager hele siden med.
|
||||
// Vagten står HER frem for ved de tre kaldesteder, fordi butikken er det
|
||||
// ene sted, der kender udgaven: en vagt pr. kalder er tre steder at glemme
|
||||
// den fjerde, og det var præcis sådan den her slap igennem.
|
||||
if (this.notesOnly) {
|
||||
// Skrevet kun hvis der ER noget at rydde. En ubetinget tilskrivning
|
||||
// looper: projektsiden har en effekt, der læser app.tasks og kalder
|
||||
// herind, så et skriv ved hvert kald er en uendelig runde —
|
||||
// effect_update_depth_exceeded, ikke en 404. Før vagten kastede kaldet
|
||||
// på 404'eren og nåede aldrig at skrive, så løkken var skjult bag en
|
||||
// fejl.
|
||||
if (this.tasks.length) this.tasks = [];
|
||||
return this.tasks;
|
||||
}
|
||||
const { tasks } = await api.listTasks({
|
||||
completed: completedView.shown ? 'include' : undefined,
|
||||
...params
|
||||
|
||||
@@ -141,6 +141,13 @@
|
||||
let startDate = $state('');
|
||||
|
||||
$effect(() => {
|
||||
// Skabeloner er projekt plus sektioner plus opgaver, så ruten findes ikke i
|
||||
// noter-udgaven — og den her fangede fejlen i en toast, altså en synlig
|
||||
// fejlbesked på indstillingssiden frem for en tom liste.
|
||||
if (app.notesOnly) {
|
||||
loadingTemplates = false;
|
||||
return;
|
||||
}
|
||||
api
|
||||
.listTemplates()
|
||||
.then((r) => (templates = r.templates))
|
||||
|
||||
@@ -296,7 +296,11 @@
|
||||
let mailConfigured = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
// Feedet er en kalender af opgaver, så adressen findes ikke i
|
||||
// noter-udgaven.
|
||||
if (!app.notesOnly) {
|
||||
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
||||
}
|
||||
api
|
||||
.getMailAddress()
|
||||
.then((r) => {
|
||||
|
||||
@@ -92,6 +92,8 @@
|
||||
let planBusy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
// Dagens plan er dagens opgaver.
|
||||
if (app.notesOnly) return;
|
||||
api
|
||||
.planSettings()
|
||||
.then((p) => (plan = p))
|
||||
|
||||
@@ -88,9 +88,14 @@
|
||||
if (!projectId) return;
|
||||
status = 'loading';
|
||||
try {
|
||||
// Sektioner findes ikke i noter-udgaven, hvor et projekt er en notesbog
|
||||
// — og det er ikke bare støj: Promise.all kaster på den første, der
|
||||
// fejler, så en 404 herfra satte status = 'failed', og siden viste en
|
||||
// fejlskærm frem for notesbogens noter. `loadTasks` vogter sig selv i
|
||||
// butikken, fordi den har tre kaldesteder her.
|
||||
const [p, s] = await Promise.all([
|
||||
api.getProject(projectId),
|
||||
api.listSections(projectId),
|
||||
app.notesOnly ? Promise.resolve({ sections: [] }) : api.listSections(projectId),
|
||||
app.loadTasks({ project_id: projectId })
|
||||
]);
|
||||
project = p;
|
||||
|
||||
Reference in New Issue
Block a user