Fix delete-row handler: nested slug extraction in OCaml dispatch

The slug extractor after (api. scanned for the first ) but for nested
URLs like (api.(delete.1)) it got "(delete.1" instead of "delete".
Now handles nested parens: extracts handler name and injects path
params into query string. Also strengthened the Playwright test to
accept confirm dialogs and assert strict row count decrease.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 19:41:32 +00:00
parent d2f4ab71d1
commit f66195ce18
2 changed files with 37 additions and 10 deletions

View File

@@ -2658,8 +2658,29 @@ let http_mode port =
if i + 5 > String.length s then ""
else if String.sub s i 5 = "(api." then
let start = i + 5 in
let end_ = try String.index_from s start ')' with Not_found -> String.length s in
String.sub s start (end_ - start)
if start < String.length s && s.[start] = '(' then
(* Nested: (api.(delete.1)) — extract "delete" as slug,
inject path param into query string *)
let inner = start + 1 in
let end_ =
let rec scan j =
if j >= String.length s then j
else match s.[j] with '.' | ')' -> j | _ -> scan (j + 1)
in scan inner in
let handler_name = String.sub s inner (end_ - inner) in
(* Extract path param value if present (after the dot) *)
if end_ < String.length s && s.[end_] = '.' then begin
let val_start = end_ + 1 in
let val_end = try String.index_from s val_start ')' with Not_found -> String.length s in
let param_val = String.sub s val_start (val_end - val_start) in
(* Append to query string so request-arg can find it *)
let sep = if !_req_query = "" then "" else "&" in
_req_query := !_req_query ^ sep ^ "item-id=" ^ param_val
end;
handler_name
else
let end_ = try String.index_from s start ')' with Not_found -> String.length s in
String.sub s start (end_ - start)
else find_api s (i + 1) in
find_api path 0 in
let handler_key = "handler:ex-" ^ slug in

View File

@@ -191,14 +191,20 @@ test.describe('Mutation handlers', () => {
test('delete-row: clicking delete removes row', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.delete-row)))');
const rowsBefore = await page.locator('table tbody tr').count();
const deleteBtn = page.locator('button:has-text("Delete")').first();
if (await deleteBtn.count() > 0 && rowsBefore > 0) {
await deleteBtn.click();
await page.waitForTimeout(2000);
const rowsAfter = await page.locator('table tbody tr').count();
// Row count should decrease or stay same (if swap replaces)
expect(rowsAfter).toBeLessThanOrEqual(rowsBefore);
// Auto-accept confirm dialogs (sx-confirm triggers window.confirm)
page.on('dialog', dialog => dialog.accept());
const rowsBefore = await page.locator('tbody#delete-rows tr').count();
expect(rowsBefore).toBeGreaterThan(0);
const firstRowId = await page.locator('tbody#delete-rows tr').first().getAttribute('id');
const deleteBtn = page.locator('tbody#delete-rows tr:first-child button').first();
await deleteBtn.click();
await page.waitForTimeout(2000);
const rowsAfter = await page.locator('tbody#delete-rows tr').count();
// Row count must strictly decrease
expect(rowsAfter).toBe(rowsBefore - 1);
// The specific row must be gone
if (firstRowId) {
await expect(page.locator(`#${firstRowId}`)).toHaveCount(0);
}
});