merge: 최신 main을 ETag 최적화 브랜치에 통합한다

This commit is contained in:
2026-08-22 06:04:33 +00:00
19 changed files with 845 additions and 163 deletions
+178 -2
View File
@@ -195,6 +195,8 @@ const installFixture = async (
tournamentStage?: number;
joinedGroupId?: number;
emptyFinalGroups?: boolean;
realtimeState?: { tournamentStage: number; totalAmount: number };
onOperation?: (operation: string, headers: Record<string, string>) => void;
} = {}
) => {
let joined = false;
@@ -213,13 +215,17 @@ const installFixture = async (
});
await page.route(gameTrpcRoute, async (route) => {
const results = operationNames(route).map((operation) => {
options.onOperation?.(operation, route.request().headers());
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: names[0] } });
if (operation === 'join.getConfig') return response({});
if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } });
if (operation === 'tournament.getAdminStatus') return response({ ok: false });
if (operation === 'tournament.getSnapshot') {
const tournamentStage = options.tournamentStage ?? (options.applicationOpen ? 1 : 0);
const tournamentStage =
options.realtimeState?.tournamentStage ??
options.tournamentStage ??
(options.applicationOpen ? 1 : 0);
const joinedGroupId = options.joinedGroupId ?? 0;
return response({
state: {
@@ -272,7 +278,7 @@ const installFixture = async (
participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10])
),
myTotals: { 1: 120, 2: 40 },
totalAmount: 2800,
totalAmount: options.realtimeState?.totalAmount ?? 2800,
myAmount: 160,
});
}
@@ -318,6 +324,44 @@ const installFixture = async (
return { placedBets };
};
const installFakeEventSource = async (page: Page) => {
await page.addInitScript(() => {
class FakeEventSource extends EventTarget {
static instances: FakeEventSource[] = [];
readonly url: string;
closed = false;
constructor(url: string | URL) {
super();
this.url = String(url);
FakeEventSource.instances.push(this);
queueMicrotask(() => {
if (!this.closed) this.dispatchEvent(new Event('open'));
});
}
close() {
this.closed = true;
}
emit(type: string, payload: unknown) {
if (this.closed) return;
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
}
}
Object.defineProperty(window, 'EventSource', { configurable: true, value: FakeEventSource });
Object.assign(window, {
__tournamentEventSourceCount: () => FakeEventSource.instances.filter((source) => !source.closed).length,
__tournamentEventSourceUrls: () =>
FakeEventSource.instances.filter((source) => !source.closed).map((source) => source.url),
__emitTournamentEvent: (type: string, payload: unknown) => {
for (const source of FakeEventSource.instances) source.emit(type, payload);
},
});
});
};
const openTournament = async (page: Page) => {
await installFixture(page);
await page.goto('tournament');
@@ -732,6 +776,138 @@ test('tournament and betting pages expose same-row navigation tabs beside close'
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
});
test('betting realtime refresh is shared across tabs and preserves local interaction state', async ({
page,
context,
}) => {
test.setTimeout(40_000);
const follower = await context.newPage();
await Promise.all([
page.setViewportSize({ width: 390, height: 844 }),
follower.setViewportSize({ width: 390, height: 844 }),
]);
const state = { tournamentStage: 6, totalAmount: 2800 };
const operations: Array<{ operation: string; grant: string | undefined }> = [];
const fixtureOptions = {
realtimeState: state,
onOperation: (operation: string, headers: Record<string, string>) => {
operations.push({ operation, grant: headers['x-sammo-realtime-access-grant'] });
},
};
await Promise.all([installFakeEventSource(page), installFakeEventSource(follower)]);
await Promise.all([installFixture(page, fixtureOptions), installFixture(follower, fixtureOptions)]);
await Promise.all([page.goto('betting'), follower.goto('betting')]);
await Promise.all([
expect(page.getByRole('tab', { name: '전력전' })).toBeVisible(),
expect(follower.getByRole('tab', { name: '전력전' })).toBeVisible(),
]);
await follower.getByRole('tab', { name: '통솔전' }).click();
await follower.getByRole('button', { name: '관우에게 베팅하기' }).click();
await follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액').selectOption('50');
await expect
.poll(async () => {
const counts = await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceCount: () => number;
}
).__tournamentEventSourceCount()
)
)
);
return counts.reduce((sum, count) => sum + count, 0);
})
.toBe(1);
const activeSourceUrls = (
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate(() =>
(
window as unknown as {
__tournamentEventSourceUrls: () => string[];
}
).__tournamentEventSourceUrls()
)
)
)
).flat();
expect(activeSourceUrls).toHaveLength(1);
expect(new URL(activeSourceUrls[0]!).searchParams.get('scope')).toBe('tournament');
const before = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
state.totalAmount = 3333;
const payload = {
type: 'tournamentViewInvalidated',
refreshGrant: 'opaque-e2e-grant',
invalidation: { snapshot: false, betting: true, rankings: false },
};
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((event) => {
(
window as unknown as {
__emitTournamentEvent: (type: string, payload: unknown) => void;
}
).__emitTournamentEvent('tournamentViewInvalidated', event);
}, payload)
)
);
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length)
.toBe(before.betting + 1);
await expect(page.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.locator('.section-title small')).toContainText('전체 금액 : 3333');
await expect(follower.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true');
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
expect(operations.filter(({ operation }) => operation === 'tournament.getSnapshot')).toHaveLength(before.snapshot);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(before.rankings);
expect(
operations.filter(
({ operation, grant }) => operation === 'tournament.getBettingSummary' && grant === 'opaque-e2e-grant'
)
).toHaveLength(1);
const recoveryBefore = {
snapshot: operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length,
betting: operations.filter(({ operation }) => operation === 'tournament.getBettingSummary').length,
rankings: operations.filter(({ operation }) => operation === 'tournament.getRankings').length,
};
for (const visibilityState of ['hidden', 'visible'] as const) {
await Promise.all(
[page, follower].map((candidate) =>
candidate.evaluate((nextVisibilityState) => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: nextVisibilityState,
});
document.dispatchEvent(new Event('visibilitychange'));
}, visibilityState)
)
);
}
await expect
.poll(() => operations.filter(({ operation }) => operation === 'tournament.getSnapshot').length)
.toBe(recoveryBefore.snapshot + 1);
expect(operations.filter(({ operation }) => operation === 'tournament.getBettingSummary')).toHaveLength(
recoveryBefore.betting + 1
);
expect(operations.filter(({ operation }) => operation === 'tournament.getRankings')).toHaveLength(
recoveryBefore.rankings + 1
);
await expect(follower.getByRole('dialog', { name: '베팅하기' })).toBeVisible();
await expect(follower.getByRole('dialog', { name: '베팅하기' }).getByLabel('베팅 금액')).toHaveValue('50');
});
test('tournament and betting close only their script-opened popup window', async ({ page }, testInfo) => {
const baseURL = testInfo.project.use.baseURL;
expect(typeof baseURL).toBe('string');