feat(fase-11): realtime re-sort on sort_order UPDATE + drag-reorder e2e (11.4)

The drag handle + dndzone + reorderItems batch UPDATE were already wired
on the list detail view (Fase 5.10). One observable contract was missing:
when another client reorders an item, the local realtime UPDATE feed
shallow-merges the new payload onto the existing row but does NOT
re-position the row in the array — so the new order is invisible until
a reload. Fix: sort the `items` array by sort_order after every realtime
UPDATE / INSERT merge. Cheap (n is small per list) and matches the cold-
load order.

E2E (reorder-items.test.ts):
  * RO-01: create 3 items, write a new sort_order via the same PATCH the
    in-page reorderItems() helper does, reload, assert the reorder
    persisted.
  * RO-02: dual-context, Ana reorders, Borja sees the new order via
    realtime within the suite's 10s window — without this commit's
    sort fix the test failed because Borja's DOM order stayed at the
    old order.

Drag emulation in headless Chromium is unreliable for svelte-dnd-action
(native HTML5 DnD). Both tests drive the same write path the page uses
(reorderItems = batch PATCH on sort_order) via fetch from inside the
browser context, using the session token the SDK has already stored.
The realtime + reload paths are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 03:26:12 +02:00
parent f28577e165
commit 1a6aae2874
2 changed files with 214 additions and 4 deletions

View File

@@ -338,10 +338,16 @@
// UPDATE payload. INSERTs of brand-new rows arrive without tags
// (they're attached separately); seed an empty array.
const next = applyItemEvent(items as unknown as ShoppingItem[], evt);
items = next.map((row) => {
const existing = items.find((i) => i.id === row.id);
return { ...row, tags: existing?.tags ?? [] };
});
// Re-sort by sort_order so a realtime UPDATE that only changes the
// row's sort_order reorders the rendered list without a refresh
// (Fase 11.4.3). Without this the array order is stable across
// shallow-merge and a remote reorder is invisible to other clients.
items = next
.map((row) => {
const existing = items.find((i) => i.id === row.id);
return { ...row, tags: existing?.tags ?? [] };
})
.sort((a, b) => a.sort_order - b.sort_order);
});
// Listen for shopping_item_tags changes so attach/detach in another tab

View File

@@ -0,0 +1,204 @@
/**
* RO-series — Drag-reorder of shopping items (Fase 11.4)
*
* The list detail view wires `svelte-dnd-action` over the unchecked-items
* section via a per-row GripVertical handle, calling reorderItems() on
* drop. These tests verify the user-observable contract:
*
* RO-01: a reorder persists across reload (sort_order is written to DB)
* RO-02: Ana's reorder propagates to Borja via realtime UPDATEs on
* shopping_items.sort_order without a refresh
*
* Note on drag emulation: svelte-dnd-action uses native HTML5 DnD which
* headless Chromium handles poorly. Rather than fight the engine, both
* tests drive the same write path the page uses (reorderItems batch
* UPDATE) by issuing the PATCH calls directly against the REST endpoint
* using the session token already in localStorage. The realtime
* propagation, the SELECT after reload, and the optimistic UI update
* paths are unchanged.
*/
import { test, expect, type Page, type Browser } from '@playwright/test';
import { USERS } from '../fixtures/users.js';
import { loginAs } from '../fixtures/login.js';
const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`;
const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i;
// Dev-stack anon key (also in root .env). Playwright doesn't load .env, and
// even when it did the LAN-IP supabase URL in there is wrong for headless.
// This is the public anon key for the dev demo instance — no secrets here.
const DEV_SUPABASE_URL = 'http://localhost:8001';
const DEV_SUPABASE_ANON_KEY =
process.env.PUBLIC_SUPABASE_ANON_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA';
async function gotoSeedList(page: Page) {
await page.goto(SEED_LIST_PATH);
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
}
async function createItem(page: Page, name: string) {
const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER);
await input.fill(name);
await input.press('Enter');
await expect(page.locator(`[role="listitem"][data-name="${name}"]`).first()).toBeVisible({
timeout: 5_000
});
await page.waitForTimeout(400);
}
/** Read item rows in document order, filtered by prefix. */
async function orderedNames(page: Page, prefix: string): Promise<string[]> {
return page.evaluate((p) => {
return Array.from(document.querySelectorAll('[role="listitem"][data-checked="false"]'))
.map((el) => (el as HTMLElement).dataset.name ?? '')
.filter((n) => n.startsWith(p));
}, prefix);
}
/**
* Reorder items inside the browser context by PATCHing sort_order directly
* to the Supabase REST endpoint via the same access token the SDK uses.
* This is what the in-page reorderItems() helper does under the hood
* (Promise.all of per-row updates).
*/
async function reorderByName(page: Page, listId: string, orderedNames: string[]) {
await page.evaluate(
async ({ listId, orderedNames, supabaseUrl, anonKey }) => {
// Find the GoTrue session token written by supabase-js v2.
let token = '';
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)!;
if (k.startsWith('sb-') && k.endsWith('-auth-token')) {
try {
const obj = JSON.parse(localStorage.getItem(k)!);
token = obj.access_token ?? obj.currentSession?.access_token ?? '';
} catch {
/* */
}
}
}
const headers: Record<string, string> = {
apikey: anonKey,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
};
const listRes = await fetch(
`${supabaseUrl}/rest/v1/shopping_items?list_id=eq.${listId}&select=id,name`,
{ headers }
);
const text = await listRes.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error(`reorderByName: bad JSON from ${supabaseUrl}/rest/v1/shopping_items: ${text.slice(0, 120)}`);
}
if (!Array.isArray(parsed)) {
throw new Error(
`reorderByName: expected array, got ${JSON.stringify(parsed).slice(0, 120)} (status ${listRes.status})`
);
}
const items = parsed as { id: string; name: string }[];
const ids = orderedNames
.map((n) => items.find((i) => i.name === n)?.id)
.filter((x): x is string => !!x);
await Promise.all(
ids.map((id, idx) =>
fetch(`${supabaseUrl}/rest/v1/shopping_items?id=eq.${id}`, {
method: 'PATCH',
headers: { ...headers, Prefer: 'return=minimal' },
body: JSON.stringify({ sort_order: idx })
})
)
);
},
{
listId,
orderedNames,
supabaseUrl: DEV_SUPABASE_URL,
anonKey: DEV_SUPABASE_ANON_KEY
}
);
}
test.describe('Drag-reorder', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, USERS.ana);
});
test('RO-01: reordering persists across reload', async ({ page }) => {
await gotoSeedList(page);
const stamp = Date.now();
const prefix = `RO01-${stamp}-`;
const names = [`${prefix}A`, `${prefix}B`, `${prefix}C`];
for (const n of names) {
await createItem(page, n);
}
const initial = await orderedNames(page, prefix);
expect(initial).toEqual(names);
// Move C above A and B.
await reorderByName(page, SEED_LIST_ID, [names[2], names[0], names[1]]);
await page.reload();
await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
const reordered = await orderedNames(page, prefix);
expect(reordered[0]).toBe(names[2]);
const aIdx = reordered.indexOf(names[0]);
const bIdx = reordered.indexOf(names[1]);
expect(aIdx).toBeGreaterThan(0);
expect(bIdx).toBeGreaterThan(aIdx);
});
});
test.describe('Drag-reorder — realtime', () => {
async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) {
const ctx = await browser.newContext();
const p = await ctx.newPage();
await loginAs(p, user);
await p.goto(SEED_LIST_PATH);
await expect(p.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
return { ctx, p };
}
test('RO-02: a reorder by Ana propagates to Borja without reload', async ({ browser }) => {
const ana = await loggedInListPage(browser, USERS.ana);
const borja = await loggedInListPage(browser, USERS.borja);
try {
const stamp = Date.now();
const prefix = `RO02-${stamp}-`;
const names = [`${prefix}X`, `${prefix}Y`];
for (const n of names) {
await createItem(ana.p, n);
}
// Both sides see the items.
for (const n of names) {
await expect(
borja.p.locator(`[role="listitem"][data-name="${n}"]`).first()
).toBeVisible({ timeout: 10_000 });
}
// Reverse the order on Ana's side.
await reorderByName(ana.p, SEED_LIST_ID, [names[1], names[0]]);
// Realtime feeds the new order to Borja within the suite's 10s window.
await expect
.poll(
async () => orderedNames(borja.p, prefix),
{ timeout: 10_000 }
)
.toEqual([names[1], names[0]]);
} finally {
await ana.ctx.close();
await borja.ctx.close();
}
});
});