fix(ui): the reader announces a file it cannot show, where it is tested

The previous commit announced the reader's failure from App.tsx's catch,
with no test on that wiring, and its message claimed the case had been
seen live. It had not: the live run covered console lines, the start
line, the failures of an unknown project through the seam (/rpc tool
errors and a /api 404), the ring mirror and the panel. This commit puts
the announcement where the failure is decided, in loadFileDocument, for
both of its shapes (no module node in the index; the server's
"(source not available)" placeholder for a file gone from disk), and
pins it with two reader tests: both shapes reach the observer as
source "reader", level warn, and a file that loads announces nothing.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-09-08 01:30:38 +02:00
parent 9dc882a82f
commit 0fe968a343
3 changed files with 62 additions and 9 deletions
-7
View File
@@ -156,7 +156,6 @@ import { CbmRpcProvider, symbolKindOf } from './provider/cbm-rpc-provider';
import MonacoReader from './reader/MonacoReader';
import type { ReaderStatus } from './reader/MonacoReader';
import { FileNotReadableError, loadFileDocument, READER_RPC_TOOL } from './reader/file-source';
import { reportError } from './provider/error-observer';
import type { ReaderDocument } from './reader/file-source';
import { badgesForLines } from './core/step-badge-decorator';
import type { SymbolRef } from './core/focus-protocol';
@@ -1760,12 +1759,6 @@ export default function App(): JSX.Element {
setDocument(undefined);
setReaderStatus(error instanceof FileNotReadableError ? 'unavailable' : 'failed');
setReaderMessage(error instanceof Error ? error.message : String(error));
// The reader shows the sentence; the frontend log keeps it
// (the /rpc failures underneath announce themselves, a file
// the index does not know does not).
if (error instanceof FileNotReadableError) {
reportError({ source: 'reader', level: 'warn', message: error.message });
}
});
},
[client, project],
+47
View File
@@ -9,6 +9,8 @@
* rpc-transport und rpc-client hindurch; ersetzt ist nur die Leitung.
*/
import { describe, expect, it } from 'vitest';
import { observeErrors } from '../provider/error-observer';
import type { ErrorReport } from '../provider/error-observer';
import { RpcIntelligenceClient } from '../provider/rpc-client';
import { FakeRpc, queryContains, rowsText } from '../test-support/rpc-recordings';
import type { Route } from '../test-support/rpc-recordings';
@@ -232,6 +234,51 @@ describe('loadFileDocument, seit dem schlanken Vertrag (#1597)', () => {
});
});
describe('loadFileDocument announces to the frontend log', () => {
it('a file without a module node, and the server placeholder, before it throws', async () => {
const reports: ErrorReport[] = [];
const stop = observeErrors((report) => reports.push(report));
try {
const missing = clientFor([
noModuleRoute,
{ tool: 'query_graph', when: queryContains('MATCH (n:File)'), text: rowsText(FILE_COLUMNS, []) },
]);
await expect(loadFileDocument(missing.client, PROJECT, 'assets/logo.svg'))
.rejects.toBeInstanceOf(FileNotReadableError);
const gone = clientFor([
moduleRoute('probe-small.src.ui.http_server', 2244),
snippetRoute({ start_line: 1, end_line: 500, source_mode: 'full', source: '(source not available)' }),
]);
await expect(loadFileDocument(gone.client, PROJECT, 'src/ui/http_server.c'))
.rejects.toBeInstanceOf(FileNotReadableError);
} finally {
stop();
}
expect(reports.map((report) => [report.source, report.level])).toEqual([
['reader', 'warn'],
['reader', 'warn'],
]);
expect(reports[0]?.message).toContain('The index has no module node for assets/logo.svg');
expect(reports[1]?.message).toContain('could not read src/ui/http_server.c');
});
it('nothing when the file loads', async () => {
const reports: ErrorReport[] = [];
const stop = observeErrors((report) => reports.push(report));
try {
const { client } = clientFor([
moduleRoute('probe-small.src.types', 3),
snippetRoute({ start_line: 1, end_line: 3, source_mode: 'full', source: 'a\nb\nc' }),
]);
await loadFileDocument(client, PROJECT, 'src/types.ts');
} finally {
stop();
}
expect(reports).toEqual([]);
});
});
describe('truncationNoteFor', () => {
it('nennt Zeilen, Deckel und den Grund, warum nicht nachgeladen wird', () => {
+15 -2
View File
@@ -30,6 +30,7 @@ import { fileNodeForPath, moduleForFile, COLUMNS } from '../provider/cypher';
import type { RpcIntelligenceClient } from '../provider/rpc-client';
import type { CodeSnippetResult } from '../provider/rpc-schemas';
import { moduleQualifiedName, moduleQnFromFileQn, normalizeWorkspacePath } from '../app/module-qn';
import { reportError } from '../provider/error-observer';
/** Das Werkzeug, aus dem der Inhalt des Readers kommt. Der Beweislauf schreibt es mit. */
export const READER_RPC_TOOL = 'get_code_snippet';
@@ -74,6 +75,18 @@ export class FileNotReadableError extends Error {
}
}
/**
* A file the reader cannot show is announced to the frontend log before it
* is thrown (provider/error-observer.ts). The /rpc failures underneath
* announce themselves; a file the index does not know, or the server's
* placeholder for a file gone from disk, is a successful call with nothing
* in it, and only this module knows that it is a failure.
*/
function notReadable(path: string, message: string): FileNotReadableError {
reportError({ source: 'reader', level: 'warn', message });
return new FileNotReadableError(path, message);
}
function toOptionalNumber(cell: string | undefined): number | undefined {
if (cell === undefined || cell === '') {
return undefined;
@@ -164,7 +177,7 @@ export async function loadFileDocument(
const node = await lookupModuleNode(client, project, path);
if (node === undefined) {
throw new FileNotReadableError(
throw notReadable(
path,
`The index has no module node for ${path}. This server delivers file content only through get_code_snippet on an indexed symbol, so a file the index did not record cannot be read here.`,
);
@@ -173,7 +186,7 @@ export async function loadFileDocument(
const qualifiedName = node.qualifiedName;
const snippet = await client.getCodeSnippet(project, qualifiedName);
if (snippet.source.length === 0 || snippet.source === SOURCE_UNAVAILABLE) {
throw new FileNotReadableError(
throw notReadable(
path,
snippet.source === SOURCE_UNAVAILABLE
? `The server could not read ${path} from the repository (${qualifiedName}): `