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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions action_buttons_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ func TestActionButtons(t *testing.T) {
}
t.Log("clear-done action passed: deleted 2 completed tasks")

// Verify in database
db, err := sql.Open("sqlite", dbPath)
// Verify in database. Use a busy_timeout so the read waits for the server's
// concurrent action writes to commit instead of failing immediately with
// SQLITE_BUSY (matches the busy_timeout the server applies to its own
// connection in internal/source).
db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("Failed to open database for verification: %v", err)
}
Expand Down
79 changes: 33 additions & 46 deletions auto_tasks_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ func (l *autoTasksConsoleLogs) get() []string {
return append([]string{}, l.logs...)
}

// toggleFirstCheckbox clicks the first checkbox in the given lvt-source section
// and waits until its checked state matches wantChecked.
//
// It uses a JS .click() rather than chromedp.Click: CDP's synthetic mouse click
// does not reliably trigger livetemplate's delegated event handlers in headless
// Docker Chrome (the same reason action_buttons_e2e_test.go uses .click()). It
// then polls for the resulting checked state instead of sleeping a fixed
// duration, so a slow WebSocket round-trip + file write no longer flakes.
func toggleFirstCheckbox(source string, wantChecked bool, timeout time.Duration) chromedp.Action {
sel := fmt.Sprintf(`document.querySelectorAll('[lvt-source="%s"] input[type="checkbox"]')`, source)
return chromedp.Tasks{
chromedp.Evaluate(sel+`[0].click()`, nil),
waitForDOM(fmt.Sprintf(`%s[0].checked === %t`, sel, wantChecked), timeout),
}
}

// createAutoTasksExample creates a temp directory with a zero-config markdown file
// containing task list sections (no frontmatter, no separate data file).
func createAutoTasksExample(t *testing.T) (string, func()) {
Expand Down Expand Up @@ -197,32 +213,15 @@ func TestAutoTasks_BasicToggle(t *testing.T) {
}
t.Log("'Make coffee' starts unchecked")

// Click the first checkbox to toggle it
// Click the first checkbox to toggle it and wait for the checked state to
// appear (WebSocket round-trip + file write). A failure here dumps console
// logs for diagnosis.
err = chromedp.Run(ctx,
chromedp.Click(`[lvt-source="_auto_morning-tasks"] input[type="checkbox"]`, chromedp.ByQuery),
chromedp.Sleep(3*time.Second), // Wait for WebSocket response + file write
)
if err != nil {
t.Fatalf("Failed to click checkbox: %v", err)
}
t.Log("Clicked first checkbox")

// Verify the checkbox is now checked in the UI
var afterChecked bool
err = chromedp.Run(ctx,
chromedp.Evaluate(`
(() => {
const checkboxes = document.querySelectorAll('[lvt-source="_auto_morning-tasks"] input[type="checkbox"]');
return checkboxes.length > 0 ? checkboxes[0].checked : false;
})()
`, &afterChecked),
toggleFirstCheckbox("_auto_morning-tasks", true, 15*time.Second),
)
if err != nil {
t.Fatalf("Failed to get state after toggle: %v", err)
}
if !afterChecked {
t.Logf("Console logs: %v", testCtx.ConsoleLogs.get())
t.Fatal("Checkbox should be checked after toggle")
t.Fatalf("Checkbox should be checked after toggle: %v", err)
}
t.Log("Checkbox is now checked in UI")

Expand Down Expand Up @@ -270,29 +269,17 @@ func TestAutoTasks_AddTask(t *testing.T) {
}
t.Log("Page loaded with auto-tasks")

// Type new task text and submit
// Type new task text and submit. Use a JS .click() on the submit button
// (CDP click is unreliable for delegated handlers in headless Docker Chrome)
// and poll for the new row instead of a fixed sleep.
err = chromedp.Run(ctx,
chromedp.SendKeys(`[lvt-source="_auto_morning-tasks"] input[name="text"]`, "Walk the dog", chromedp.ByQuery),
chromedp.Click(`[lvt-source="_auto_morning-tasks"] button[type="submit"]`, chromedp.ByQuery),
chromedp.Sleep(3*time.Second), // Wait for WebSocket + file write
)
if err != nil {
t.Fatalf("Failed to add task: %v", err)
}
t.Log("Submitted new task 'Walk the dog'")

// Verify new task appears in UI
var newTaskCount int
err = chromedp.Run(ctx,
chromedp.Evaluate(`document.querySelectorAll('[lvt-source="_auto_morning-tasks"] input[type="checkbox"]').length`, &newTaskCount),
chromedp.Evaluate(`document.querySelector('[lvt-source="_auto_morning-tasks"] button[type="submit"]').click()`, nil),
waitForDOM(`document.querySelectorAll('[lvt-source="_auto_morning-tasks"] input[type="checkbox"]').length === 4`, 15*time.Second),
)
if err != nil {
t.Fatalf("Failed to count tasks after add: %v", err)
}

if newTaskCount != 4 {
t.Logf("Console logs: %v", testCtx.ConsoleLogs.get())
t.Fatalf("Expected 4 tasks after adding, got %d", newTaskCount)
t.Fatalf("Failed to add task: %v", err)
}
t.Log("New task appears in UI (4 total)")

Expand Down Expand Up @@ -393,12 +380,12 @@ func TestAutoTasks_PersistAcrossReload(t *testing.T) {
t.Fatalf("Failed to navigate: %v", err)
}

// Toggle first checkbox
// Toggle first checkbox and wait for the checked state before reloading
err = chromedp.Run(ctx,
chromedp.Click(`[lvt-source="_auto_morning-tasks"] input[type="checkbox"]`, chromedp.ByQuery),
chromedp.Sleep(3*time.Second),
toggleFirstCheckbox("_auto_morning-tasks", true, 15*time.Second),
)
if err != nil {
t.Logf("Console logs: %v", testCtx.ConsoleLogs.get())
t.Fatalf("Failed to toggle: %v", err)
}
t.Log("Toggled first checkbox")
Expand Down Expand Up @@ -473,12 +460,12 @@ func TestAutoTasks_NoFullReload(t *testing.T) {
}
t.Log("Reload marker set")

// Toggle checkbox
// Toggle checkbox and wait for the checked state (watcher + refresh cycle)
err = chromedp.Run(ctx,
chromedp.Click(`[lvt-source="_auto_morning-tasks"] input[type="checkbox"]`, chromedp.ByQuery),
chromedp.Sleep(5*time.Second), // Wait for watcher + refresh cycle
toggleFirstCheckbox("_auto_morning-tasks", true, 15*time.Second),
)
if err != nil {
t.Logf("Console logs: %v", testCtx.ConsoleLogs.get())
t.Fatalf("Failed to toggle: %v", err)
}
t.Log("Toggled checkbox with file watcher active")
Expand Down
8 changes: 4 additions & 4 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"analyze": "node -e \"const fs=require('fs');const m=JSON.parse(fs.readFileSync('dist/meta.json'));Object.entries(m.outputs).forEach(([k,v])=>console.log(k,Math.round(v.bytes/1024)+'KB'));\""
},
"dependencies": {
"@livetemplate/client": "^0.11.9",
"@livetemplate/client": "^0.14.3",
"monaco-editor": "^0.45.0"
},
"devDependencies": {
Expand Down
24 changes: 12 additions & 12 deletions internal/assets/client/tinkerdown-client.browser.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions internal/assets/client/tinkerdown-client.browser.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/source/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func QuerySQLiteSchema(dbPath, table, siteDir string) []ColumnInfo {
dbPath = filepath.Join(siteDir, dbPath)
}

db, err := sql.Open("sqlite", dbPath)
db, err := sql.Open("sqlite", sqliteDSN(dbPath))
if err != nil {
return nil
}
Expand Down
17 changes: 16 additions & 1 deletion internal/source/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func NewSQLiteSource(name, dbPath, table, siteDir string, readonly bool) (*SQLit
dbPath = siteDir + "/" + dbPath
}

db, err := sql.Open("sqlite", dbPath)
db, err := sql.Open("sqlite", sqliteDSN(dbPath))
if err != nil {
return nil, fmt.Errorf("sqlite source %q: failed to open database: %w", name, err)
}
Expand Down Expand Up @@ -453,6 +453,21 @@ func normalizeSQLiteType(sqlType string) string {

// Helper functions

// sqliteBusyTimeoutMs is how long a connection waits for a lock held by another
// writer before returning SQLITE_BUSY. SQLite's default is 0 (fail immediately),
// which makes any concurrent access — e.g. a reader querying while an action's
// write is committing — flake with "database is locked". A non-zero timeout lets
// the brief write window drain and the query succeed.
const sqliteBusyTimeoutMs = 5000

// sqliteDSN builds the modernc.org/sqlite connection string for a database file
// path, attaching a busy_timeout pragma so concurrent access waits for locks
// instead of failing immediately. Applies to every SQLite connection the library
// opens, regardless of source or example.
func sqliteDSN(dbPath string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbPath, sqliteBusyTimeoutMs)
}

func isValidIdentifier(name string) bool {
if name == "" || len(name) > 64 {
return false
Expand Down
23 changes: 23 additions & 0 deletions internal/source/sqlite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,26 @@ func TestSQLiteSource_AutoCreateHasCreatedAt(t *testing.T) {
t.Fatalf("expected 1 row, got %d", len(data))
}
}

// TestSQLiteSource_BusyTimeoutApplied verifies that NewSQLiteSource opens its
// connection with a non-zero busy_timeout. A malformed DSN pragma is silently
// ignored by modernc.org/sqlite, so this guards against the lock-contention
// flake (issue #292) silently regressing.
func TestSQLiteSource_BusyTimeoutApplied(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "busy.db")

src, err := NewSQLiteSource("items", dbPath, "items", dir, false)
if err != nil {
t.Fatalf("NewSQLiteSource failed: %v", err)
}
defer src.Close()

var timeout int
if err := src.db.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
t.Fatalf("failed to read busy_timeout: %v", err)
}
if timeout != sqliteBusyTimeoutMs {
t.Fatalf("expected busy_timeout %d, got %d (DSN pragma not applied?)", sqliteBusyTimeoutMs, timeout)
}
}
Loading