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>
205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
/**
|
|
* 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();
|
|
}
|
|
});
|
|
});
|