|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { JcefEventBus } from '@/jcef/eventBus'; |
| 3 | +import { createClientRequestHandle } from '@/components/SSERequest/clientRequestHandle'; |
| 4 | +import type { SSERequestCallbacks } from '@/components/SSERequest'; |
| 5 | +import { |
| 6 | + buildStreamErrorOutput, |
| 7 | + dispatchDesktopStreamRequest, |
| 8 | + describeRequestFailure, |
| 9 | + extractEnvelopeErrorMessage, |
| 10 | + streamEventName, |
| 11 | +} from './requestFailure'; |
| 12 | + |
| 13 | +const testExtractEnvelopeErrorMessage = () => { |
| 14 | + const errorEnvelope = JSON.stringify({ |
| 15 | + actionType: 'error', |
| 16 | + uuid: 'req-1', |
| 17 | + message: { success: false, errorCode: 'model.invalid', errorMessage: 'AI model is not available' }, |
| 18 | + }); |
| 19 | + assert.equal(extractEnvelopeErrorMessage(errorEnvelope), 'AI model is not available'); |
| 20 | + |
| 21 | + assert.equal( |
| 22 | + extractEnvelopeErrorMessage({ actionType: 'error', message: { success: false } }), |
| 23 | + 'AI stream request failed', |
| 24 | + ); |
| 25 | + |
| 26 | + const failedActionResultOnly = { actionType: 'execute', message: { success: false, errorMessage: 'boom' } }; |
| 27 | + assert.equal(extractEnvelopeErrorMessage(failedActionResultOnly), 'boom'); |
| 28 | + |
| 29 | + assert.equal(extractEnvelopeErrorMessage(JSON.stringify({ actionType: 'execute', uuid: 'req-1' })), undefined); |
| 30 | + assert.equal(extractEnvelopeErrorMessage({ actionType: 'execute', message: { success: true } }), undefined); |
| 31 | + assert.equal(extractEnvelopeErrorMessage('not json'), undefined); |
| 32 | + assert.equal(extractEnvelopeErrorMessage(undefined), undefined); |
| 33 | + assert.equal(extractEnvelopeErrorMessage(42), undefined); |
| 34 | +}; |
| 35 | + |
| 36 | +const testDescribeRequestFailure = () => { |
| 37 | + assert.equal(describeRequestFailure(500, 'bridge unavailable'), 'bridge unavailable'); |
| 38 | + assert.equal(describeRequestFailure(500, ' '), 'AI stream request failed (500)'); |
| 39 | + assert.equal(describeRequestFailure(undefined, undefined), 'AI stream request failed'); |
| 40 | +}; |
| 41 | + |
| 42 | +const testPublishedFailureSettlesStreamHandle = async () => { |
| 43 | + const eventName = streamEventName('failed-before-stream'); |
| 44 | + const errors: Error[] = []; |
| 45 | + const successes: unknown[][] = []; |
| 46 | + const callbacks: SSERequestCallbacks<any> = { |
| 47 | + onSuccess: (chunks) => successes.push(chunks), |
| 48 | + onError: (error) => errors.push(error), |
| 49 | + onUpdate: () => {}, |
| 50 | + }; |
| 51 | + const handle = createClientRequestHandle({ |
| 52 | + callbacks, |
| 53 | + eventBus: JcefEventBus, |
| 54 | + eventName, |
| 55 | + handleErrorPayload: () => false, |
| 56 | + }); |
| 57 | + |
| 58 | + JcefEventBus.publish(eventName, buildStreamErrorOutput('AI model is not available')); |
| 59 | + |
| 60 | + await assert.rejects(handle.done, /AI model is not available/); |
| 61 | + assert.equal(errors.length, 1); |
| 62 | + assert.equal(successes.length, 0); |
| 63 | + |
| 64 | + // The listener must be removed once settled: a late event changes nothing. |
| 65 | + JcefEventBus.publish(eventName, buildStreamErrorOutput('late')); |
| 66 | + assert.equal(errors.length, 1); |
| 67 | +}; |
| 68 | + |
| 69 | +const createFailureHandle = (requestId: string) => { |
| 70 | + const errors: Error[] = []; |
| 71 | + const updates: unknown[] = []; |
| 72 | + const handle = createClientRequestHandle({ |
| 73 | + callbacks: { |
| 74 | + onSuccess: () => assert.fail('failed stream must not succeed'), |
| 75 | + onError: (error) => errors.push(error), |
| 76 | + onUpdate: (output) => updates.push(output), |
| 77 | + }, |
| 78 | + eventBus: JcefEventBus, |
| 79 | + eventName: streamEventName(requestId), |
| 80 | + handleErrorPayload: () => false, |
| 81 | + }); |
| 82 | + return { handle, errors, updates }; |
| 83 | +}; |
| 84 | + |
| 85 | +const testSynchronousErrorEnvelopeWaitsForListener = async () => { |
| 86 | + const requestId = 'sync-error-envelope'; |
| 87 | + dispatchDesktopStreamRequest({ |
| 88 | + requestId, |
| 89 | + serializedRequest: '{}', |
| 90 | + javaQuery: ({ onSuccess }) => { |
| 91 | + onSuccess( |
| 92 | + JSON.stringify({ |
| 93 | + actionType: 'error', |
| 94 | + message: { success: false, errorMessage: 'model unavailable' }, |
| 95 | + }), |
| 96 | + ); |
| 97 | + }, |
| 98 | + publish: JcefEventBus.publish, |
| 99 | + }); |
| 100 | + const { handle, errors, updates } = createFailureHandle(requestId); |
| 101 | + |
| 102 | + await assert.rejects(handle.done, /model unavailable/); |
| 103 | + assert.equal(errors.length, 1); |
| 104 | + assert.equal(updates.length, 1); |
| 105 | +}; |
| 106 | + |
| 107 | +const testSynchronousNativeFailureWaitsForListener = async () => { |
| 108 | + const requestId = 'sync-native-failure'; |
| 109 | + dispatchDesktopStreamRequest({ |
| 110 | + requestId, |
| 111 | + serializedRequest: '{}', |
| 112 | + javaQuery: ({ onFailure }) => onFailure(503, 'bridge unavailable'), |
| 113 | + publish: JcefEventBus.publish, |
| 114 | + }); |
| 115 | + const { handle, errors } = createFailureHandle(requestId); |
| 116 | + |
| 117 | + await assert.rejects(handle.done, /bridge unavailable/); |
| 118 | + assert.equal(errors.length, 1); |
| 119 | + JcefEventBus.publish(streamEventName(requestId), buildStreamErrorOutput('late')); |
| 120 | + assert.equal(errors.length, 1); |
| 121 | +}; |
| 122 | + |
| 123 | +const testMissingAndThrowingBridgePublishFailures = async () => { |
| 124 | + const published: Array<{ eventName: string; output: ReturnType<typeof buildStreamErrorOutput> }> = []; |
| 125 | + const publish = (eventName: string, output: ReturnType<typeof buildStreamErrorOutput>) => { |
| 126 | + published.push({ eventName, output }); |
| 127 | + }; |
| 128 | + |
| 129 | + dispatchDesktopStreamRequest({ requestId: 'missing', serializedRequest: '{}', publish }); |
| 130 | + dispatchDesktopStreamRequest({ |
| 131 | + requestId: 'throwing', |
| 132 | + serializedRequest: '{}', |
| 133 | + javaQuery: () => { |
| 134 | + throw new Error('bridge threw'); |
| 135 | + }, |
| 136 | + publish, |
| 137 | + }); |
| 138 | + await Promise.resolve(); |
| 139 | + |
| 140 | + assert.deepEqual( |
| 141 | + published.map(({ eventName }) => eventName), |
| 142 | + [streamEventName('missing'), streamEventName('throwing')], |
| 143 | + ); |
| 144 | + assert.match(published[0].output.data, /Java Query is not available/); |
| 145 | + assert.match(published[1].output.data, /bridge threw/); |
| 146 | +}; |
| 147 | + |
| 148 | +const testNormalAcknowledgementDoesNotPublish = async () => { |
| 149 | + const published: unknown[] = []; |
| 150 | + dispatchDesktopStreamRequest({ |
| 151 | + requestId: 'normal-ack', |
| 152 | + serializedRequest: '{}', |
| 153 | + javaQuery: ({ onSuccess }) => onSuccess(JSON.stringify({ actionType: 'execute', message: { success: true } })), |
| 154 | + publish: (...args) => published.push(args), |
| 155 | + }); |
| 156 | + await Promise.resolve(); |
| 157 | + assert.deepEqual(published, []); |
| 158 | +}; |
| 159 | + |
| 160 | +const main = async () => { |
| 161 | + testExtractEnvelopeErrorMessage(); |
| 162 | + testDescribeRequestFailure(); |
| 163 | + await testPublishedFailureSettlesStreamHandle(); |
| 164 | + await testSynchronousErrorEnvelopeWaitsForListener(); |
| 165 | + await testSynchronousNativeFailureWaitsForListener(); |
| 166 | + await testMissingAndThrowingBridgePublishFailures(); |
| 167 | + await testNormalAcknowledgementDoesNotPublish(); |
| 168 | + console.log('sse request failure tests passed'); |
| 169 | +}; |
| 170 | + |
| 171 | +main().catch((error) => { |
| 172 | + console.error(error); |
| 173 | + process.exitCode = 1; |
| 174 | +}); |
0 commit comments