Additive test coverage across server, editor-ext, client and mcp. #192 — AiChatService.stream integration (Section 3, against real Postgres): - new apps/server/test/integration/ai-chat-stream.int-spec.ts drives the real streamText through a seeded ai/test MockLanguageModelV3 and a real Node ServerResponse, covering: onError persists an assistant error record (status 'error' + partial answer + provider cause in metadata); external MCP client closed exactly once on BOTH onFinish and onError; anti-tamper — history is rebuilt from the DB transcript, not from body.messages. #206 — red-team findings (most already fixed+tested in #212): - mdrt-2 (UNFIXED, data loss): turndown.dataloss.test.ts documents that pageBreak / transclusionReference / mention are silently dropped on Markdown export (characterization + it.fails for the desired survive-export contract). - persist-6 (UNFIXED, data loss): persistence-store.spec.ts adds an it.failing documenting that a momentarily-empty live doc overwrites non-empty content (left unfixed — a store-side empty-guard is a behaviour change). #204 — test-strategy plan, highest-priority subset: - Phase 1: mcp-clients.lease.spec.ts covers the external MCP client lease/refcount/eviction lifecycle (leak / premature-close / double-close). - Phase 2 data-integrity pure functions: editor-ext table-utils (transpose/moveRow/convert round-trip) and math tokenizer false-positive guard; client emoji-menu (+ it.fails for the unguarded localStorage JSON.parse bug), sort-cells, normalizeTableColumnWidths; mcp htmlEmbed/ pageBreak markdown data-loss + footnote-diff; server export getInternalLinkPageName extensionless-path bug — FIXED (small/clear) + tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,32 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// #206 persist-6 — RED (it.failing): a momentarily-empty live Y.Doc must not
|
||||
// overwrite non-empty persisted content. `onStoreDocument` empty-guards the
|
||||
// LOAD path but not the STORE path, so today an empty doc (a client/agent
|
||||
// glitch, a bad merge, an emptying transclusion) is written straight over the
|
||||
// page and the content is wiped silently. A store-side empty-guard is a real
|
||||
// behaviour change (a deliberate "select-all + delete" is also empty), so it
|
||||
// is left UNFIXED pending a product decision; this documents the data-loss
|
||||
// path and flips to a normal passing test the moment the guard lands.
|
||||
it.failing(
|
||||
'does NOT overwrite non-empty content with a momentarily-empty live doc (persist-6)',
|
||||
async () => {
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: doc('IMPORTANT RICH CONTENT'),
|
||||
});
|
||||
|
||||
await ext.onStoreDocument(buildData(document, 'user') as any);
|
||||
|
||||
// Desired contract: the empty incoming doc is rejected and the rich page
|
||||
// survives. Today updatePage is called with the empty content (data loss).
|
||||
expect(pageRepo.updatePage).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
// persist-1 — when every attempt fails the hook must NOT report a phantom
|
||||
// success: no "page.updated" badge broadcast and no history snapshot for
|
||||
// content that was never written.
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { McpClientsService } from './mcp-clients.service';
|
||||
|
||||
/**
|
||||
* #204 (Phase 1, highest-value MCP gap) — external MCP client lease / refcount /
|
||||
* eviction lifecycle.
|
||||
*
|
||||
* `toolsFor` hands the streaming turn a release handle; the real transports must
|
||||
* be closed EXACTLY once and only when (a) the cache entry has been evicted AND
|
||||
* (b) no turn still leases it. The bugs this guards against:
|
||||
* - leak: an evicted entry whose clients are never closed (refCount stuck > 0);
|
||||
* - premature close: a TTL/CRUD eviction closing a client a turn is still
|
||||
* executing tool calls against;
|
||||
* - double close: a release handle closing the same client more than once.
|
||||
*
|
||||
* The private `buildEntry` is stubbed so no real network/MCP connection happens;
|
||||
* we drive only the lease bookkeeping in `toolsFor` / `release` / `evict` /
|
||||
* `invalidate`, which is the untested surface.
|
||||
*/
|
||||
describe('McpClientsService lease/refcount/eviction', () => {
|
||||
type FakeClient = { tools: () => Promise<any>; close: jest.Mock };
|
||||
|
||||
function fakeClient(): FakeClient {
|
||||
return {
|
||||
tools: async () => ({}),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
// Minimal CacheEntry the service's lease logic operates on.
|
||||
function makeEntry(clients: FakeClient[]) {
|
||||
const timer = setTimeout(() => {}, 60_000);
|
||||
timer.unref?.();
|
||||
return {
|
||||
tools: {},
|
||||
clients,
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
expiresAt: Date.now() + 60_000,
|
||||
refCount: 0,
|
||||
evicted: false,
|
||||
closed: false,
|
||||
timer,
|
||||
} as any;
|
||||
}
|
||||
|
||||
let service: McpClientsService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new McpClientsService({} as any, {} as any);
|
||||
});
|
||||
|
||||
function stubBuild(entry: any) {
|
||||
jest.spyOn(service as any, 'buildEntry').mockResolvedValue(entry);
|
||||
}
|
||||
|
||||
it('leases on toolsFor and keeps the client warm (no close) on release', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const lease = await service.toolsFor('ws-1');
|
||||
expect(entry.refCount).toBe(1);
|
||||
|
||||
await lease.clients[0].close();
|
||||
// Released but NOT evicted: the cached entry stays warm for reuse, so the
|
||||
// transport must NOT be closed yet.
|
||||
expect(entry.refCount).toBe(0);
|
||||
expect(client.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defers close when an entry is evicted while still leased, then closes once on release', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const lease = await service.toolsFor('ws-2');
|
||||
(service as any).evict(entry);
|
||||
|
||||
// Evicted under an active lease: close is deferred to the last release.
|
||||
expect(entry.evicted).toBe(true);
|
||||
expect(client.close).not.toHaveBeenCalled();
|
||||
|
||||
await lease.clients[0].close();
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
expect(entry.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('shares one entry across concurrent leases; closes only after the LAST release', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const lease1 = await service.toolsFor('ws-3');
|
||||
const lease2 = await service.toolsFor('ws-3');
|
||||
expect(entry.refCount).toBe(2);
|
||||
|
||||
(service as any).evict(entry);
|
||||
|
||||
await lease1.clients[0].close();
|
||||
// One lease remains: a stream could still be running — must stay open.
|
||||
expect(entry.refCount).toBe(1);
|
||||
expect(client.close).not.toHaveBeenCalled();
|
||||
|
||||
await lease2.clients[0].close();
|
||||
expect(entry.refCount).toBe(0);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('release is idempotent: closing the same handle twice decrements once and closes once', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const lease = await service.toolsFor('ws-4');
|
||||
(service as any).evict(entry);
|
||||
|
||||
await lease.clients[0].close();
|
||||
await lease.clients[0].close();
|
||||
|
||||
expect(entry.refCount).toBe(0); // not -1
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('evicting an unleased entry closes its clients immediately', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const built = await (service as any).getOrBuildEntry('ws-5');
|
||||
expect(built.refCount).toBe(0);
|
||||
|
||||
(service as any).evict(entry);
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
expect(entry.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('invalidate (TTL/CRUD) does NOT close a client that a turn still leases', async () => {
|
||||
const client = fakeClient();
|
||||
const entry = makeEntry([client]);
|
||||
stubBuild(entry);
|
||||
|
||||
const lease = await service.toolsFor('ws-6');
|
||||
expect(entry.refCount).toBe(1);
|
||||
|
||||
service.invalidate('ws-6');
|
||||
// invalidate evicts asynchronously once the build promise resolves.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(entry.evicted).toBe(true);
|
||||
// Still leased: the mid-turn eviction must not pull the transport.
|
||||
expect(client.close).not.toHaveBeenCalled();
|
||||
|
||||
await lease.clients[0].close();
|
||||
expect(client.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,19 @@ describe('getInternalLinkPageName', () => {
|
||||
expect(getInternalLinkPageName('Parent/My%20Page.md')).toBe('My Page');
|
||||
});
|
||||
|
||||
it('keeps the full basename when the path has no extension (#204)', () => {
|
||||
// An extensionless link target must NOT be stripped to an empty string —
|
||||
// there is no extension to drop. Previously `.split('.').slice(0,-1)`
|
||||
// collapsed "My Page" to "" and the internal link rendered with no text.
|
||||
expect(getInternalLinkPageName('Parent/My%20Page')).toBe('My Page');
|
||||
expect(getInternalLinkPageName('Just A Name')).toBe('Just A Name');
|
||||
});
|
||||
|
||||
it('preserves dots in a dotted name that has a real extension (#204)', () => {
|
||||
// "v1.2.md" -> "v1.2": only the final ".md" segment is the extension.
|
||||
expect(getInternalLinkPageName('docs/v1.2.md')).toBe('v1.2');
|
||||
});
|
||||
|
||||
it('falls back to the raw name without throwing on malformed encoding', () => {
|
||||
// "%E0%A4" is an incomplete escape; decodeURIComponent throws and the
|
||||
// helper returns the raw (still-encoded) name.
|
||||
|
||||
@@ -106,7 +106,14 @@ export function replaceInternalLinks(
|
||||
}
|
||||
|
||||
export function getInternalLinkPageName(path: string, currentFilePath?: string): string {
|
||||
const name = path?.split('/').pop().split('.').slice(0, -1).join('.');
|
||||
// Strip a trailing file extension from the basename, but only when there IS
|
||||
// one: an extensionless link target (e.g. "My Page") has no extension to drop,
|
||||
// so `split('.').slice(0,-1)` would otherwise collapse it to an empty string,
|
||||
// producing an internal link with no visible text (#204 export bug). Dotted
|
||||
// page names without an extension (e.g. "v1.2") keep their dots.
|
||||
const base = path?.split('/').pop();
|
||||
const parts = base?.split('.');
|
||||
const name = parts && parts.length > 1 ? parts.slice(0, -1).join('.') : base;
|
||||
try {
|
||||
return decodeURIComponent(name);
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user