test(e2e): unblock the 3 fixme tests in modifier-profiles spec
P6 (source chain in pinned panel):
Required two server-side changes to make the badge actually meaningful:
- Add `profile` field to GameStatePayload schema (server emits it,
client receives it) so multiplayer clients see the room's active
profile metadata, not just the modifier facts.
- Make `ChessEngine.activeProfile` mutable via `setActiveProfile()`
so PredictionManager can sync it from `game.state` snapshots.
Also wire `modifier-profile.updated` through GameClient + Prediction-
Manager so hot-swap broadcasts update the engine's profile field
reactively.
Fix Lobby.handlePlaySolo's resetToFreshGame to forward the selected
profile to the new ChessEngine — otherwise the local engine had
modifier facts (via server reconcile) but no profile metadata,
breaking source-chain attribution and any other profile-aware UI.
P7 (multiplayer propose → approve → both observe updated):
P8 (multiplayer propose → reject → no updated broadcast):
Implemented at the WS-protocol level using two parallel raw sockets
per test (mirrors multiplayer.spec.ts pattern). Critical sequencing:
- Both sockets opened concurrently via Promise.all so opponent is
listening BEFORE host's propose arrives at the server (otherwise
proposal-pending broadcasts to nobody and the test deadlocks).
- Token must travel at the envelope level, not in payload, for the
server's reconnect-by-token path to fire (otherwise hits ROOM_FULL
on the second connection from each player).
- game.move payload uses algebraic notation strings ('a2', 'a3'), not
square indices — the protocol schema only accepts strings.
- Host re-uses original room.create token, opponent re-uses their
join token. Server's reconnectManager treats both as grace-window
reconnects since the original WS closed cleanly.
Verification:
- 1231 unit tests pass (96 files)
- 58/58 Playwright tests pass in 1.9 min (was 55 + 3 fixme)
- Total Playwright surface coverage: solo-smoke (7) + multiplayer (2) +
full-flow (1) + layouts (24) + modifier-profiles (24 — including all
8 T2-polish tests, 0 fixme).
This commit is contained in:
parent
748dde5d4c
commit
567480a788
8 changed files with 647 additions and 42 deletions
|
|
@ -7,7 +7,7 @@
|
|||
*
|
||||
* Runs against the local dev server (no WS server needed — solo play only).
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
test.describe('Modifier Profiles', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -975,50 +975,586 @@ test.describe('Modifier Profiles — T2 polish', () => {
|
|||
|
||||
// T2-P6 — Source-chain breakdown in the pinned panel.
|
||||
//
|
||||
// `ModifierPinnedPanel` collects the modifier SOURCE via
|
||||
// `getModifierSource()` but doesn't currently render it — see
|
||||
// packages/chess/src/ui/ModifierPinnedPanel.tsx: the `source` value
|
||||
// is computed, placed on the row object, and then dropped on the
|
||||
// floor (only `label` and `description` are emitted to the DOM).
|
||||
// Until the panel wires the source into a visible element we can't
|
||||
// assert on it from the outside.
|
||||
test.fixme(
|
||||
'source chain (per-instance vs per-type) surfaced in pinned panel',
|
||||
async () => {
|
||||
// Depends on ModifierPinnedPanel rendering row.source in a
|
||||
// test-discoverable element (e.g. `source-badge`). See
|
||||
// docs/adr/modifier-profiles.md §T2 enhanced source chain.
|
||||
},
|
||||
);
|
||||
// `ModifierPinnedPanel` renders a `source-badge-{kindId}` element per
|
||||
// modifier row. We seed a profile that mixes per-type (HP +1 on all
|
||||
// pawns) and per-instance (Range +1 on the b1 knight) modifiers,
|
||||
// create a multiplayer room with that profile, then click each piece
|
||||
// in turn to assert its badge text matches the profile source.
|
||||
//
|
||||
// Falls back to test.skip when no WS server is running (mirrors P5).
|
||||
test('source chain (per-instance vs per-type) surfaced in pinned panel', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Close the editor + drawer left open by beforeEach so the lobby is
|
||||
// navigable cleanly.
|
||||
await page.keyboard.press('Escape');
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const entry = {
|
||||
id: 't2-p6-source-chain',
|
||||
name: 'Source-Chain Fixture',
|
||||
profile: {
|
||||
id: 't2-p6-source-chain',
|
||||
name: 'Source-Chain Fixture',
|
||||
description: 'Mixes per-type + per-instance modifiers for T2-P6.',
|
||||
layoutId: 'classic',
|
||||
perType: [
|
||||
// All pawns gain +1 HP — per-type source.
|
||||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
|
||||
],
|
||||
perInstance: [
|
||||
// The b1 knight specifically gains +1 range — per-instance source.
|
||||
{ kind: 'range-bonus', square: 'b1', value: 1 },
|
||||
],
|
||||
version: 1,
|
||||
source: 'custom',
|
||||
},
|
||||
starred: false,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||||
{ key: LIBRARY_KEY, e: entry },
|
||||
);
|
||||
await page.reload();
|
||||
|
||||
const picker = page.getByTestId('profile-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.selectOption('t2-p6-source-chain');
|
||||
|
||||
await page.click('[data-action="create-room"]');
|
||||
await Promise.race([
|
||||
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
|
||||
page
|
||||
.getByTestId('lobby-error')
|
||||
.waitFor({ state: 'visible', timeout: 5000 }),
|
||||
]);
|
||||
|
||||
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
|
||||
test.skip(!isOnGamePage, 'No WS server — source-chain assertion skipped');
|
||||
|
||||
// Click the b1 knight (per-instance: range-bonus) and verify badges.
|
||||
await page.click('[data-square="b1"]');
|
||||
const panel = page.getByTestId('modifier-pinned-panel');
|
||||
await expect(panel).toBeVisible({ timeout: 3000 });
|
||||
|
||||
// Range-bonus row should show "per-instance: b1".
|
||||
const rangeBadge = page.getByTestId('source-badge-range-bonus');
|
||||
await expect(rangeBadge).toBeVisible();
|
||||
await expect(rangeBadge).toContainText(/per-instance:\s*b1/i);
|
||||
|
||||
// Now pin a pawn (per-type source). Close current pin first by
|
||||
// clicking the same piece again (toggle), or click another piece.
|
||||
await page.click('[data-square="a2"]');
|
||||
// Wait for panel to repaint with the pawn's data.
|
||||
await expect(panel).toContainText(/pawn/i, { timeout: 2000 });
|
||||
|
||||
const hpBadge = page.getByTestId('source-badge-hp-bonus');
|
||||
await expect(hpBadge).toBeVisible();
|
||||
await expect(hpBadge).toContainText(/per-type:\s*all\s+(white|both)\s+pawns/i);
|
||||
});
|
||||
|
||||
// T2-P7 — Multiplayer proposal approve → both clients see
|
||||
// `modifier-profile.updated` after the next move.
|
||||
//
|
||||
// Requires two BrowserContexts to simulate the two WS clients.
|
||||
// This spec currently runs with a single `page` fixture; wiring a
|
||||
// second context + sharing the room code across the two is a
|
||||
// non-trivial harness addition. Unit-level coverage for the
|
||||
// protocol state machine lives at
|
||||
// `packages/server/src/ws.modifier-profile-consent.test.ts`
|
||||
// (T3 integration tests), which exercises the same server
|
||||
// endpoints that this e2e would drive.
|
||||
test.fixme(
|
||||
'multiplayer: propose → approve → both clients observe updated',
|
||||
async () => {
|
||||
// Implement with `test('...', async ({ browser }) => { ... })`
|
||||
// spawning two contexts, one creating the room and one joining.
|
||||
},
|
||||
);
|
||||
// Spawns two browser contexts via the `browser` fixture (mirroring
|
||||
// packages/chess/e2e/multiplayer.spec.ts). Host (white) creates a
|
||||
// room with a baseline profile, opponent (black) joins via raw WS,
|
||||
// both navigate into MultiplayerGameView. Host then sends
|
||||
// `modifier-profile.propose` from inside its page context; opponent
|
||||
// approves; host plays a move (drains the queue per T2-ADR-1); both
|
||||
// clients observe `modifier-profile.updated`.
|
||||
//
|
||||
// Skipped when the WS server isn't running.
|
||||
test('multiplayer: propose → approve → both clients observe updated', async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Skip if no WS server (consistent with multiplayer.spec.ts).
|
||||
let wsUp = false;
|
||||
try {
|
||||
const res = await fetch('http://localhost:7357/healthz');
|
||||
wsUp = res.ok;
|
||||
} catch {
|
||||
wsUp = false;
|
||||
}
|
||||
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
||||
|
||||
// We exercise the propose/consent flow purely at the WS-protocol
|
||||
// level using two raw WebSockets per player (one connection per
|
||||
// player slot — the server's reconnect-by-token semantics would
|
||||
// otherwise treat a second socket as a take-over). We never open
|
||||
// the React UI for these tests; the protocol is what we're verifying,
|
||||
// not the UI dialog (which has its own coverage in T9 unit tests).
|
||||
const ctxHost = await browser.newContext();
|
||||
const ctxOpp = await browser.newContext();
|
||||
const pageHost = await ctxHost.newPage();
|
||||
const pageOpp = await ctxOpp.newPage();
|
||||
|
||||
try {
|
||||
// Host creates room (no initial profile — baseline classic game).
|
||||
await pageHost.goto('/');
|
||||
await pageHost.waitForSelector('[data-testid="page-home"]');
|
||||
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
||||
|
||||
// Opponent joins the same room from a separate context.
|
||||
await pageOpp.goto('/');
|
||||
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
||||
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
|
||||
|
||||
// Define the candidate profile.
|
||||
const candidateProfile = {
|
||||
id: 't2-p7-propose',
|
||||
name: 'Propose Fixture',
|
||||
description: 'Used by T2-P7 e2e.',
|
||||
perType: [
|
||||
{ kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 },
|
||||
],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: 'custom',
|
||||
};
|
||||
|
||||
// Run host and opponent socket flows CONCURRENTLY via Promise.all
|
||||
// so opponent is connected and listening BEFORE the host's propose
|
||||
// arrives at the server. Otherwise proposal-pending would be sent
|
||||
// to nobody and lost.
|
||||
//
|
||||
// Host script: connect (reconnect via token), wait briefly so the
|
||||
// opponent has time to connect too, send propose, then wait for
|
||||
// game.state and send a move to drain the pending queue, then
|
||||
// resolve when modifier-profile.updated arrives.
|
||||
//
|
||||
// Opponent script: connect (reconnect via token), wait for
|
||||
// proposal-pending, send consent=approve, then resolve when
|
||||
// modifier-profile.updated arrives (proves the swap applied).
|
||||
const [hostFlow, oppFlow] = await Promise.all([
|
||||
pageHost.evaluate(
|
||||
async ({ roomCode, token, profile }) => {
|
||||
return new Promise<{
|
||||
proposed: boolean;
|
||||
sawUpdated: boolean;
|
||||
types: string[];
|
||||
}>((resolve) => {
|
||||
const types: string[] = [];
|
||||
let proposed = false;
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => resolve({ proposed, sawUpdated: false, types }),
|
||||
10000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.join',
|
||||
token,
|
||||
payload: { code: roomCode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload?: { code?: string; message?: string };
|
||||
};
|
||||
types.push(
|
||||
msg.type === 'error'
|
||||
? `error(${msg.payload?.code ?? '?'}:${msg.payload?.message ?? '?'})`
|
||||
: msg.type,
|
||||
);
|
||||
if (msg.type === 'room.joined' || msg.type === 'game.state') {
|
||||
// Wait 500ms for opponent to connect then propose ONCE.
|
||||
if (!proposed) {
|
||||
proposed = true;
|
||||
setTimeout(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
type: 'modifier-profile.propose',
|
||||
payload: { roomCode, candidate: profile, version: 0 },
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
}
|
||||
} else if (msg.type === 'modifier-profile.consent-received') {
|
||||
// Send a move 500ms later to drain the pending queue.
|
||||
setTimeout(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 3,
|
||||
ts: Date.now(),
|
||||
type: 'game.move',
|
||||
payload: { from: 'a2', to: 'a3' },
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
} else if (msg.type === 'modifier-profile.updated') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve({ proposed, sawUpdated: true, types });
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ proposed, sawUpdated: false, types });
|
||||
};
|
||||
});
|
||||
},
|
||||
{ roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile },
|
||||
),
|
||||
pageOpp.evaluate(
|
||||
async ({ roomCode, token }) => {
|
||||
return new Promise<{
|
||||
receivedPending: boolean;
|
||||
sawUpdated: boolean;
|
||||
types: string[];
|
||||
}>((resolve) => {
|
||||
const types: string[] = [];
|
||||
let receivedPending = false;
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => resolve({ receivedPending, sawUpdated: false, types }),
|
||||
10000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.join',
|
||||
token,
|
||||
payload: { code: roomCode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as { type: string };
|
||||
types.push(msg.type);
|
||||
if (msg.type === 'modifier-profile.proposal-pending') {
|
||||
receivedPending = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
type: 'modifier-profile.consent',
|
||||
payload: { roomCode, decision: 'approve' },
|
||||
}),
|
||||
);
|
||||
} else if (msg.type === 'modifier-profile.updated') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve({ receivedPending, sawUpdated: true, types });
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ receivedPending, sawUpdated: false, types });
|
||||
};
|
||||
});
|
||||
},
|
||||
{ roomCode: roomHost.code, token: roomOpp.token },
|
||||
),
|
||||
]);
|
||||
|
||||
expect(hostFlow.proposed).toBe(true);
|
||||
expect(oppFlow.receivedPending).toBe(true);
|
||||
// Both clients must observe the updated broadcast.
|
||||
if (!hostFlow.sawUpdated || !oppFlow.sawUpdated) {
|
||||
// Surface the actual message stream to diagnose.
|
||||
throw new Error(
|
||||
`updated not seen | hostTypes: ${hostFlow.types.join(',')} | oppTypes: ${oppFlow.types.join(',')}`,
|
||||
);
|
||||
}
|
||||
expect(hostFlow.sawUpdated).toBe(true);
|
||||
expect(oppFlow.sawUpdated).toBe(true);
|
||||
} finally {
|
||||
await ctxHost.close();
|
||||
await ctxOpp.close();
|
||||
}
|
||||
});
|
||||
|
||||
// T2-P8 — Multiplayer proposal reject → no `modifier-profile.updated`.
|
||||
//
|
||||
// Same multiplayer-harness gap as T2-P7. Covered at the server
|
||||
// integration level in ws.modifier-profile-consent.test.ts
|
||||
// ("reject → both get modifier-profile.rejected reason=rejected").
|
||||
test.fixme(
|
||||
'multiplayer: propose → reject → no updated broadcast',
|
||||
async () => {
|
||||
// See T2-P7 fixme note.
|
||||
},
|
||||
);
|
||||
// Same harness as P7, but opponent sends consent=reject. Host's profile
|
||||
// never enters the queue; no indicators appear after the next move.
|
||||
test('multiplayer: propose → reject → no updated broadcast', async ({
|
||||
browser,
|
||||
}) => {
|
||||
let wsUp = false;
|
||||
try {
|
||||
const res = await fetch('http://localhost:7357/healthz');
|
||||
wsUp = res.ok;
|
||||
} catch {
|
||||
wsUp = false;
|
||||
}
|
||||
test.skip(!wsUp, 'No WS server on :7357 — multiplayer test skipped');
|
||||
|
||||
// Same WS-protocol-level approach as P7 — no React UI, just two
|
||||
// raw connections per player.
|
||||
const ctxHost = await browser.newContext();
|
||||
const ctxOpp = await browser.newContext();
|
||||
const pageHost = await ctxHost.newPage();
|
||||
const pageOpp = await ctxOpp.newPage();
|
||||
|
||||
try {
|
||||
await pageHost.goto('/');
|
||||
await pageHost.waitForSelector('[data-testid="page-home"]');
|
||||
const roomHost = await wsCreateRoomNoProfile(pageHost);
|
||||
|
||||
await pageOpp.goto('/');
|
||||
await pageOpp.waitForSelector('[data-testid="page-home"]');
|
||||
const roomOpp = await wsJoinRoomShared(pageOpp, roomHost.code);
|
||||
|
||||
const candidateProfile = {
|
||||
id: 't2-p8-reject',
|
||||
name: 'Reject Fixture',
|
||||
description: 'Used by T2-P8 e2e.',
|
||||
perType: [
|
||||
{ kind: 'hp-bonus', pieceType: 'knight', color: 'both', value: 2 },
|
||||
],
|
||||
perInstance: [],
|
||||
version: 1,
|
||||
source: 'custom',
|
||||
};
|
||||
|
||||
// Same parallel pattern as P7. Host proposes after opponent is
|
||||
// listening, then plays a move. Opponent rejects on receipt.
|
||||
// Both flows assert that `modifier-profile.updated` NEVER appears
|
||||
// (the rejected proposal must never reach the apply stage).
|
||||
const [hostFlow, oppFlow] = await Promise.all([
|
||||
pageHost.evaluate(
|
||||
async ({ roomCode, token, profile }) => {
|
||||
return new Promise<{ proposed: boolean; types: string[] }>((resolve) => {
|
||||
const types: string[] = [];
|
||||
let proposed = false;
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const settle = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve({ proposed, types });
|
||||
}, 6000);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.join',
|
||||
token,
|
||||
payload: { code: roomCode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as { type: string };
|
||||
types.push(msg.type);
|
||||
if ((msg.type === 'room.joined' || msg.type === 'game.state') && !proposed) {
|
||||
proposed = true;
|
||||
setTimeout(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
type: 'modifier-profile.propose',
|
||||
payload: { roomCode, candidate: profile, version: 0 },
|
||||
}),
|
||||
);
|
||||
}, 500);
|
||||
} else if (msg.type === 'modifier-profile.rejected') {
|
||||
// After opponent rejects, send a move and wait briefly
|
||||
// to confirm no `updated` broadcast follows.
|
||||
setTimeout(() => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 3,
|
||||
ts: Date.now(),
|
||||
type: 'game.move',
|
||||
payload: { from: 'a2', to: 'a3' },
|
||||
}),
|
||||
);
|
||||
}, 200);
|
||||
setTimeout(() => {
|
||||
clearTimeout(settle);
|
||||
ws.close();
|
||||
resolve({ proposed, types });
|
||||
}, 2500);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(settle);
|
||||
resolve({ proposed, types });
|
||||
};
|
||||
});
|
||||
},
|
||||
{ roomCode: roomHost.code, token: roomHost.token, profile: candidateProfile },
|
||||
),
|
||||
pageOpp.evaluate(
|
||||
async ({ roomCode, token }) => {
|
||||
return new Promise<{ receivedPending: boolean; types: string[] }>((resolve) => {
|
||||
const types: string[] = [];
|
||||
let receivedPending = false;
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const settle = setTimeout(() => {
|
||||
ws.close();
|
||||
resolve({ receivedPending, types });
|
||||
}, 6000);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.join',
|
||||
token,
|
||||
payload: { code: roomCode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as { type: string };
|
||||
types.push(msg.type);
|
||||
if (msg.type === 'modifier-profile.proposal-pending') {
|
||||
receivedPending = true;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
type: 'modifier-profile.consent',
|
||||
payload: { roomCode, decision: 'reject' },
|
||||
}),
|
||||
);
|
||||
} else if (msg.type === 'modifier-profile.rejected') {
|
||||
// Wait briefly to confirm no `updated` follows, then resolve.
|
||||
setTimeout(() => {
|
||||
clearTimeout(settle);
|
||||
ws.close();
|
||||
resolve({ receivedPending, types });
|
||||
}, 2500);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(settle);
|
||||
resolve({ receivedPending, types });
|
||||
};
|
||||
});
|
||||
},
|
||||
{ roomCode: roomHost.code, token: roomOpp.token },
|
||||
),
|
||||
]);
|
||||
|
||||
expect(hostFlow.proposed).toBe(true);
|
||||
expect(oppFlow.receivedPending).toBe(true);
|
||||
// The rejected profile must NEVER reach the apply stage.
|
||||
expect(hostFlow.types).not.toContain('modifier-profile.updated');
|
||||
expect(oppFlow.types).not.toContain('modifier-profile.updated');
|
||||
} finally {
|
||||
await ctxHost.close();
|
||||
await ctxOpp.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiplayer helpers (used by T2-P7, T2-P8). Mirror the patterns in
|
||||
// packages/chess/e2e/multiplayer.spec.ts but inlined here so this spec
|
||||
// stays self-contained.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a room via raw WS — no profile. Returns {code, token, color}. */
|
||||
async function wsCreateRoomNoProfile(
|
||||
p: Page,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
return p.evaluate(async () => {
|
||||
return new Promise<{ code: string; token: string; color: string }>(
|
||||
(resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('wsCreateRoom: timeout')),
|
||||
5000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.create',
|
||||
payload: {},
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload: { code: string; token: string; color: string };
|
||||
};
|
||||
if (msg.type === 'room.created') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(msg.payload);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('wsCreateRoom: error'));
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Join a room via raw WS. Returns {code, token, color}. */
|
||||
async function wsJoinRoomShared(
|
||||
p: Page,
|
||||
code: string,
|
||||
): Promise<{ code: string; token: string; color: string }> {
|
||||
return p.evaluate(async (roomCode: string) => {
|
||||
return new Promise<{ code: string; token: string; color: string }>(
|
||||
(resolve, reject) => {
|
||||
const ws = new WebSocket('ws://localhost:7357/ws');
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('wsJoinRoom: timeout')),
|
||||
5000,
|
||||
);
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
v: 1,
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
type: 'room.join',
|
||||
payload: { code: roomCode },
|
||||
}),
|
||||
);
|
||||
};
|
||||
ws.onmessage = (e: MessageEvent) => {
|
||||
const msg = JSON.parse(e.data as string) as {
|
||||
type: string;
|
||||
payload: { code: string; token: string; color: string };
|
||||
};
|
||||
if (msg.type === 'room.joined') {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
resolve(msg.payload);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('wsJoinRoom: error'));
|
||||
};
|
||||
},
|
||||
);
|
||||
}, code);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue