diff --git a/relay/helper/stream_scanner_test.go b/relay/helper/stream_scanner_test.go index 2b49753b..69896079 100644 --- a/relay/helper/stream_scanner_test.go +++ b/relay/helper/stream_scanner_test.go @@ -302,58 +302,91 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) { t.Parallel() const numChunks = 50 - const upstreamDelay = 10 * time.Millisecond - const handlerDelay = 20 * time.Millisecond - pr, pw := io.Pipe() - var upstreamBusy atomic.Int64 + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + handlerEntered := make(chan struct{}) + releaseHandler := make(chan struct{}) + readAhead := make(chan struct{}) + producerDone := make(chan struct{}) + done := make(chan struct{}) + var releaseOnce sync.Once + t.Cleanup(func() { + cancel() + releaseOnce.Do(func() { close(releaseHandler) }) + _ = pr.Close() + _ = pw.Close() + for _, worker := range []<-chan struct{}{producerDone, done} { + select { + case <-worker: + case <-time.After(time.Second): + t.Error("stream test worker did not stop after cancellation") + } + } + }) go func() { + defer close(producerDone) defer pw.Close() - feedStart := time.Now() for i := 0; i < numChunks; i++ { - fmt.Fprintf(pw, "data: {\"id\":%d}\n", i) - time.Sleep(upstreamDelay) + if _, err := fmt.Fprintf(pw, "data: {\"id\":%d}\n", i); err != nil { + return + } + if i == 1 { + close(readAhead) + } } fmt.Fprint(pw, "data: [DONE]\n") - upstreamBusy.Store(int64(time.Since(feedStart))) }() recorder := httptest.NewRecorder() c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx) resp := &http.Response{Body: pr} info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}} var count atomic.Int64 - var handlerBusy atomic.Int64 - start := time.Now() - done := make(chan struct{}) + var received []string go func() { + defer close(done) StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) { - chunkStart := time.Now() - time.Sleep(handlerDelay) - handlerBusy.Add(int64(time.Since(chunkStart))) - count.Add(1) + if count.Add(1) == 1 { + close(handlerEntered) + select { + case <-releaseHandler: + case <-ctx.Done(): + return + } + } + received = append(received, data) }) - close(done) }() + // A coupled scanner cannot read the second pipe write while its first + // callback is blocked. Synchronize that contract without sleep-speed ratios. + for _, phase := range []struct { + name string + done <-chan struct{} + }{{"first handler", handlerEntered}, {"scanner read-ahead", readAhead}} { + select { + case <-phase.done: + case <-ctx.Done(): + t.Fatalf("timed out waiting for %s", phase.name) + } + } + assert.Equal(t, int64(1), count.Load(), "only the blocked first callback can run") + releaseOnce.Do(func() { close(releaseHandler) }) select { case <-done: - case <-time.After(15 * time.Second): + case <-ctx.Done(): t.Fatal("StreamScannerHandler did not complete in time") } - - elapsed := time.Since(start) assert.Equal(t, int64(numChunks), count.Load()) - - // 用实测耗时估算"耦合"基线,避免系统繁忙时 sleep 超时偏移导致名义值失真 - coupledTime := time.Duration(upstreamBusy.Load() + handlerBusy.Load()) - t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime) - - assert.Less(t, elapsed, coupledTime*85/100, - "decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime) + assert.Equal(t, numChunks, info.ReceivedResponseCount) + require.Len(t, received, numChunks) + for i, data := range received { + assert.Equal(t, fmt.Sprintf("{\"id\":%d}", i), data) + } + assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason) } func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) { diff --git a/web/default/scripts/run-tests.mjs b/web/default/scripts/run-tests.mjs index e2cbdb71..579060b0 100644 --- a/web/default/scripts/run-tests.mjs +++ b/web/default/scripts/run-tests.mjs @@ -274,6 +274,7 @@ function runTestFiles(files, label, bunArguments) { if (files.length === 0) return 'skipped' console.log(`\n==> ${label} (${files.length} file${files.length === 1 ? '' : 's'})`) + const startedAt = performance.now() const result = Bun.spawnSync({ cmd: buildTestCommand(files, bunArguments), cwd: projectRoot, @@ -281,6 +282,14 @@ function runTestFiles(files, label, bunArguments) { stdout: 'pipe', stderr: 'pipe', }) + console.log( + `MAX_API_TEST_STAGE ${JSON.stringify({ + label, + fileCount: files.length, + exitCode: result.exitCode, + wallMilliseconds: Math.round(performance.now() - startedAt), + })}` + ) writeChildOutput(result) if (result.exitCode === 0) return 'passed' diff --git a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx index 778d35d0..cae1abad 100644 --- a/web/default/src/features/keys/components/api-key-routing-editor.test.tsx +++ b/web/default/src/features/keys/components/api-key-routing-editor.test.tsx @@ -29,6 +29,7 @@ import type { ApiKeyGroupOption } from './api-key-group-combobox' let ApiKeyRoutingEditor: typeof import('./api-key-routing-editor').ApiKeyRoutingEditor let cleanup: typeof import('@testing-library/react/pure').cleanup let fireEvent: typeof import('@testing-library/react/pure').fireEvent +let isInaccessible: typeof import('@testing-library/react/pure').isInaccessible let render: typeof import('@testing-library/react/pure').render let within: typeof import('@testing-library/react/pure').within @@ -87,7 +88,7 @@ before(async (): Promise => { }), }) // React and Base UI must detect the browser before mounting interactive portals. - ;({ cleanup, fireEvent, render, within } = + ;({ cleanup, fireEvent, isInaccessible, render, within } = await import('@testing-library/react/pure')) ;({ ApiKeyRoutingEditor } = await import('./api-key-routing-editor')) }) @@ -147,6 +148,37 @@ function RoutingHarness(props: RoutingHarnessProps): ReactElement { ) } +async function findRoutingOptions( + view: ReturnType +): Promise> { + // Compute each visible option's accessible name once instead of rescanning + // every option for each group. Keep the same role, visibility and name checks. + const names = new Map() + const options = await view.findAllByRole('option', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, + }) + const result = new Map() + for (const option of options) { + const name = names.get(option) + assert.ok(name !== undefined) + assert.equal(result.has(name), false, `duplicate option name: ${name}`) + result.set(name, option) + } + return result +} + +function routingOption( + options: Map, + name: string +): HTMLElement { + const option = options.get(name) + assert.ok(option, `missing accessible routing option: ${name}`) + return option +} + describe('ApiKeyRoutingEditor', (): void => { for (const initialGroups of [[], groups]) { test(`clears ${initialGroups.length} stored manual selections when automatic routing is disabled`, async (): Promise => { @@ -164,15 +196,13 @@ describe('ApiKeyRoutingEditor', (): void => { ) fireEvent.click(view.getByRole('combobox', { name: 'Select groups' })) - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}1x Ratio`, - }) + const option = routingOption(options, `${group}1x Ratio`) assert.notEqual(option.getAttribute('aria-disabled'), 'true') } - fireEvent.click(view.getByRole('option', { name: 'group-141x Ratio' })) + fireEvent.click(routingOption(options, 'group-141x Ratio')) within(view.getByRole('combobox', { name: 'Select groups' })).getByText( 'group-14' ) @@ -225,27 +255,28 @@ describe('ApiKeyRoutingEditor', (): void => { fireEvent.click(trigger) assert.equal(trigger.getAttribute('aria-expanded'), 'true') - const options = await view.findAllByRole('option') - assert.equal(options.length, availableGroups.length) + const options = await findRoutingOptions(view) + assert.equal(options.size, availableGroups.length) for (const group of availableGroups) { - const option = view.getByRole('option', { - name: `${group}${group}1x Ratio`, - }) + const option = routingOption(options, `${group}${group}1x Ratio`) assert.equal( option.getAttribute('aria-disabled'), String(!groups.includes(group)) ) } - fireEvent.click( - view.getByRole('option', { name: 'group-14group-141x Ratio' }) - ) + fireEvent.click(routingOption(options, 'group-14group-141x Ratio')) assert.equal(changedGroups, undefined) - const removeButtons = view.getAllByRole('button', { - name: /^Remove group-/, - }) + const removeButtons = view.getAllByLabelText(/^Remove group-/) assert.equal(removeButtons.length, MAX_MANUAL_ROUTING_GROUPS) for (const removeButton of removeButtons) { + assert.equal(removeButton.tagName, 'BUTTON') + assert.ok( + removeButton.getAttribute('role') === null || + removeButton.getAttribute('role') === 'button' + ) + assert.equal(removeButton.getAttribute('aria-labelledby'), null) + assert.equal(isInaccessible(removeButton), false) assert.equal(trigger.contains(removeButton), false) } diff --git a/web/default/src/features/smart-ops/alerts.test.tsx b/web/default/src/features/smart-ops/alerts.test.tsx index 30e5f65e..d764f06b 100644 --- a/web/default/src/features/smart-ops/alerts.test.tsx +++ b/web/default/src/features/smart-ops/alerts.test.tsx @@ -133,6 +133,7 @@ function ManualSettlementEvidenceHarness(): ReactElement { return ( <> + @@ -662,22 +663,31 @@ describe('SmartOps active alerts', () => { ) try { - await waitFor(() => { - assert.ok( - within(view.container).getByRole('button', { - name: 'Enter exact quota', - }) - ) + const { exactQuotaButton, causeErrorButton } = await waitFor(() => { + // Resolve both controls by role and accessible name in one scan. + const names = new Map() + const buttons = within(view.container).getAllByRole('button', { + name: (name: string, element: Element): boolean => { + names.set(element, name) + return true + }, + }) + const namedButton = (name: string): HTMLElement => { + const matches = buttons.filter((button) => names.get(button) === name) + assert.equal( + matches.length, + 1, + `expected one accessible ${name} button` + ) + return matches[0] + } + return { + exactQuotaButton: namedButton('Enter exact quota'), + causeErrorButton: namedButton('Cause reconciliation error'), + } }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') - await view.click( - within(view.container).getByRole('button', { - name: 'Enter exact quota', - }) - ) + await view.click(exactQuotaButton) const dialog = await waitFor(() => { const currentDialog = document.body.querySelector( @@ -748,6 +758,9 @@ describe('SmartOps active alerts', () => { ) try { + const causeErrorButton = within(view.container).getByRole('button', { + name: 'Cause reconciliation error', + }) await view.click( await waitFor(() => within(view.container).getByRole('checkbox', { @@ -785,9 +798,6 @@ describe('SmartOps active alerts', () => { return button }) - const causeErrorButton = view.container.querySelector( - 'button' - ) as HTMLButtonElement assert.equal(causeErrorButton.textContent, 'Cause reconciliation error') await view.click(causeErrorButton) await waitFor(() => {