From bd8a3f990d408a1902cad520fcc61db5a6c1b799 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Fri, 25 Sep 2026 00:53:10 +0900 Subject: [PATCH] =?UTF-8?q?test(checkout):=20A2c=20=E3=81=AE=E5=90=8C?= =?UTF-8?q?=E5=BA=97=20hold=20=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=202=20?= =?UTF-8?q?=E4=BB=B6=E3=82=92=20fake=20clock=20=E3=81=AB=E7=B5=B1=E4=B8=80?= =?UTF-8?q?=E3=81=97=E3=81=A6=E6=B1=BA=E5=AE=9A=E5=8C=96=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(flaky=20=E4=BF=AE=E6=AD=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「unresolved old payment scopes the relay checkout hold and notice」は、fixture の時刻が実時刻で 発行される一方 vi.waitFor が fake 時計を進め、遅延 import の完了前に通知が描画されると recovery のタイマーが未設置のまま期限判定に進む競合で、CI と並列負荷で時々落ちていた (#622 の CI・R14 レビューで 7 回中 2 回)。時計を setup 前に固定し、vi.dynamicImportSettled() を 待ってから期限の 1ms 前と期限へ明示的に進める。元の assert はすべて残し、期限ちょうどに 状態を 1 回読むことを追加で固定した。隣の「expiry read holds payment until %s completes」も 同じ原因で単独実行だと必ず落ちていた (先の test が module を読み込むおかげで通っていた) ため 同じ形に直した。単独 25/25・併走 10/10・負荷下の file 全体 368/368。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- tests/components/CheckoutForm.test.tsx | 45 +++++++++++++++++++------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tests/components/CheckoutForm.test.tsx b/tests/components/CheckoutForm.test.tsx index 27816825..79a8045a 100644 --- a/tests/components/CheckoutForm.test.tsx +++ b/tests/components/CheckoutForm.test.tsx @@ -3533,21 +3533,36 @@ describe('A2c saved-order-only notification', () => { return { ...saved, state: 'signed' as const, bind: { ...saved.bind, validBefore }, intent: { ...saved.intent, validBefore, issuedAt: Date.now() } }; } it.each(['relay', 'standard', 'different-shop'] as const)('unresolved old payment scopes the %s checkout hold and notice', async (kind) => { + // Fixture issuance, hook loading and recovery deadlines must share one clock. + vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-24T00:00:00Z')); setupRelayReady(); const record = unresolvedRecord(); saveOrderDelivery(record); - const client = await realRelay(); vi.useFakeTimers(); - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ ok: true, state: 'indeterminate' })); + const holdUntil = Number(record.intent.validBefore) * 1000; + const client = await realRelay(); + const statusReadTimes: number[] = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + statusReadTimes.push(Date.now()); + return Response.json({ ok: true, state: 'indeterminate' }); + }); const params = { ...savedParams, orderId: 'new', ...(kind === 'standard' ? { mode: 'standard' as const } : {}), ...(kind === 'different-shop' ? { to: '0x9999999999999999999999999999999999999999' as Address } : {}) }; const page = render(); - await vi.waitFor(async () => { - await act(async () => vi.advanceTimersByTimeAsync(0)); - expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); - }, { timeout: 2000, interval: 10 }); + // The notice precedes the lazy recovery import. Await its timers being installed; + // vi.waitFor would advance time while that import is still racing real I/O. + await act(async () => { + await vi.dynamicImportSettled(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); const button = screen.getByRole('button', { name: /JPYC を支払う/ }); if (kind === 'different-shop') expect(button).toBeEnabled(); else expect(button).toBeDisabled(); expect(screen.queryByText('お支払いが完了しました')).toBeNull(); - await act(async () => vi.advanceTimersByTimeAsync(300_001)); + await act(async () => vi.advanceTimersByTimeAsync(holdUntil - Date.now() - 1)); + if (kind === 'different-shop') expect(button).toBeEnabled(); else expect(button).toBeDisabled(); + expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(statusReadTimes.at(-1)).toBe(holdUntil); expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeEnabled(); expect(screen.getByText(kind === 'different-shop' ? '以前のお支払いを確認中です。このお店へのお支払いは続けられます。' : '前回の支払いの状態を確認できませんでした。支払い直す前にお店のスタッフに確認してください。')).toBeInTheDocument(); + expect(screen.queryByText('お支払いが完了しました')).toBeNull(); expect(loadOrderDelivery().kind).toBe('ready'); expect(fetchSpy.mock.calls.every(([url]) => url === '/api/relay/jpyc/status')).toBe(true); page.unmount(); client.clear(); fetchSpy.mockRestore(); vi.useRealTimers(); @@ -3574,11 +3589,17 @@ describe('A2c saved-order-only notification', () => { }); }); const page = render(); - await vi.waitFor(async () => { - await act(async () => vi.advanceTimersByTimeAsync(0)); - expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); - }, { timeout: 2000, interval: 10 }); - await act(async () => vi.advanceTimersByTimeAsync(expiry - Date.now())); + // Await this mount's lazy recovery timers even when no earlier test loaded the chunk. + await act(async () => { + await vi.dynamicImportSettled(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(expiry - Date.now() - 1)); + expect(expiryReads).toEqual([]); + expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeDisabled(); + expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument(); + await act(async () => vi.advanceTimersByTimeAsync(1)); expect(expiryReads).toEqual([expiry]); expect(screen.getByRole('button', { name: /JPYC を支払う/ })).toBeDisabled(); expect(screen.getByText('前回の支払いを確認中です。まだ支払い直さないでください。')).toBeInTheDocument();