Drives in a real browser the reader workflows v1 proved only through the API or the component suite, including an export that leaves the browser as a file. Final run: 91 checks (the 38 existing M11 checks plus 53 new), 0 failed, 0 skipped, on the production build over trusted-LAN HTTPS. - tools/m11_browser.py: scenarios for Retry and takes, Save Point create / restore / Redo, state correction (accepted, and a refused correction with its reason), narration length reaching each turn's prompt, failed generation (an unserved model blocked up front; a listed model that cannot narrate failing in the open) and recovery, and export download from the library and from campaign settings, imported into a fresh application. Rows are tagged M11 / WP-C and counted separately; --only for development. The M11 checks now wait on conditions instead of sleeping. - tools/m11_webdriver.py: Firefox download preferences, a $HOME-only download folder, a download wait that ignores partial, empty, pre-existing and still-growing files, centred real clicks, tabs, and condition waits. - tests/test_v11_c_browser_helpers.py: the download wait, prefs and $HOME guard, without a browser. - frontend: a correction the story refused was presented as "Generation failed" with a Retry offer and a typed-input claim. It is now "That correction was not applied", not retryable, with the reason kept (errors.js, FailureNotice.jsx; 3 regression tests). - DEVELOPMENT.md: the harness command, download profile and $HOME rule, what counts as a finished download, and the no-sleep rule. - docs: V1.1-PLAN, VERSION v4.4, planning README, reports/v1.1/V1.1-WP-C-REPORT.md. Open for the owner: "Correct" on an Important Facts row is always refused (K1), and a partly refused correction is not reachable from the reader UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VvegagkhuCZoFPdv4M1egY
109 lines
5.1 KiB
React
109 lines
5.1 KiB
React
/* A05 must behave the same however the failure arrives.
|
|
*
|
|
* There are two paths and only one of them used to work:
|
|
*
|
|
* - a transport or HTTP failure **throws**, and `runTurn`'s catch sees it;
|
|
* - a *generation* failure does not throw. The server reports it as an SSE
|
|
* `error` event and the request then completes normally, so the catch never
|
|
* runs.
|
|
*
|
|
* Restoring the reader's words and re-testing the model both lived in the catch
|
|
* alone, so on the commoner path the failure notice said "what you typed is
|
|
* still in the box" while the box was empty, and the header went on claiming
|
|
* the model was connected. Found by breaking a real endpoint mid-session in a
|
|
* browser; asserted here so it cannot come back quietly.
|
|
*
|
|
* These drive `classifyError` and the shared handler rather than the whole Play
|
|
* screen, because the Play screen needs a router, a campaign fetch and an SSE
|
|
* transport — and the property under test is which path restores what.
|
|
*/
|
|
|
|
import { screen } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { classifyError, KIND } from '../../errors'
|
|
import { FailureNotice } from './FailureNotice'
|
|
import { renderWith } from '../../test/helpers'
|
|
|
|
beforeEach(() => { vi.restoreAllMocks() })
|
|
|
|
describe('the failure notice states only what is true', () => {
|
|
it('claims the typed words were kept — so the caller must actually keep them', async () => {
|
|
// This assertion is the contract the defect broke. Every failed turn shows
|
|
// it, so every path that shows it owes the reader their text.
|
|
await renderWith(
|
|
<FailureNotice failure={classifyError('Could not connect to http://127.0.0.1:9/v1')}
|
|
partial={null} onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
|
)
|
|
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('classifies an unreachable endpoint as a model failure, whichever path found it', () => {
|
|
// The SSE error event carries `detail`; a thrown error carries `message`.
|
|
// Both are the same string from the same provider, so both classify alike.
|
|
const fromEvent = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
|
|
const fromThrow = classifyError('Could not connect to http://127.0.0.1:9/v1 — is the AI server running?')
|
|
expect(fromEvent.kind).toBe(KIND.MODEL)
|
|
expect(fromEvent).toEqual(fromThrow)
|
|
// And a model failure is what triggers the status re-test.
|
|
expect(fromEvent.kind).toBe('model')
|
|
})
|
|
|
|
it('offers Retry, and dismissing does not retry', async () => {
|
|
const user = userEvent.setup()
|
|
const onRetry = vi.fn()
|
|
const onDismiss = vi.fn()
|
|
await renderWith(
|
|
<FailureNotice failure={classifyError('The AI endpoint timed out.')}
|
|
partial={null} onRetry={onRetry} onDismiss={onDismiss} />,
|
|
)
|
|
await user.click(screen.getByRole('button', { name: 'Dismiss this message' }))
|
|
expect(onDismiss).toHaveBeenCalled()
|
|
expect(onRetry).not.toHaveBeenCalled()
|
|
await user.click(screen.getByRole('button', { name: 'Try that turn again' }))
|
|
expect(onRetry).toHaveBeenCalled()
|
|
})
|
|
|
|
// v1.1 WP-C, found in the browser: a correction the story refused fell through
|
|
// to "Generation failed", offered to try the turn again, and claimed the
|
|
// typed words were kept — none of which a State-panel correction involves.
|
|
const REFUSAL = "That correction can't be applied — no fact 'f1' to invalidate."
|
|
|
|
it('classifies a refused correction as a state refusal, not a failed turn', () => {
|
|
const refusal = classifyError(REFUSAL)
|
|
expect(refusal.kind).toBe(KIND.STATE)
|
|
expect(refusal.title).toBe('That correction was not applied')
|
|
expect(refusal.retryable).toBe(false)
|
|
expect(refusal.keptInput).toBe(false)
|
|
expect(refusal.detail).toBe(REFUSAL)
|
|
})
|
|
|
|
it('shows a refused correction with its reason, and no turn to retry', async () => {
|
|
await renderWith(
|
|
<FailureNotice failure={classifyError(REFUSAL)} partial={null}
|
|
onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
|
)
|
|
expect(screen.getByText('That correction was not applied')).toBeInTheDocument()
|
|
expect(screen.getByText(/no fact 'f1' to invalidate/)).toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: 'Try that turn again' })).toBeNull()
|
|
expect(screen.queryByText(/what you typed is still in the box/)).toBeNull()
|
|
})
|
|
|
|
it('still claims the typed words were kept for a failed turn', async () => {
|
|
await renderWith(
|
|
<FailureNotice failure={classifyError('boom')} partial={null}
|
|
onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
|
)
|
|
expect(screen.getByText(/what you typed is still in the box/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('labels partial prose as not kept rather than showing it as story', async () => {
|
|
await renderWith(
|
|
<FailureNotice failure={classifyError('boom')} partial="The tavern door swung"
|
|
onRetry={vi.fn()} onDismiss={vi.fn()} />,
|
|
)
|
|
expect(screen.getByText(/this was not kept/)).toBeInTheDocument()
|
|
expect(screen.getByText(/The tavern door swung/)).toBeInTheDocument()
|
|
})
|
|
})
|