gogcli-mcp 2.23.1 → 2.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/index.js +283 -69
- package/dist/lib.js +293 -71
- package/manifest.json +3 -3
- package/package.json +1 -1
- package/server.json +2 -2
- package/src/gmail-results.ts +239 -0
- package/src/lib.ts +9 -0
- package/src/pagination.ts +108 -0
- package/src/runner.ts +1 -1
- package/src/tools/auth.ts +39 -12
- package/src/tools/calendar.ts +28 -7
- package/src/tools/classroom.ts +46 -28
- package/src/tools/drive.ts +6 -4
- package/src/tools/gmail.ts +37 -6
- package/src/tools/utils.ts +36 -5
- package/src/worker.ts +1 -1
- package/tests/gmail-results.test.ts +285 -0
- package/tests/page-cursor-contract.test.ts +50 -0
- package/tests/pagination.test.ts +102 -0
- package/tests/tools/auth.test.ts +60 -0
- package/tests/tools/calendar.test.ts +81 -3
- package/tests/tools/gmail.test.ts +163 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import * as runner from '../src/runner.js';
|
|
4
|
+
import { finalizeGmailSearch, fetchGmailPages } from '../src/gmail-results.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('../src/runner.js');
|
|
7
|
+
|
|
8
|
+
beforeEach(() => vi.clearAllMocks());
|
|
9
|
+
|
|
10
|
+
const THREADS = (nextPageToken: string) => JSON.stringify({
|
|
11
|
+
threads: [
|
|
12
|
+
{ id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
13
|
+
{ id: 'b', internalDateIso: '2026-08-12T12:36:00-04:00' },
|
|
14
|
+
{ id: 'c', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
15
|
+
],
|
|
16
|
+
nextPageToken,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const THREAD_OPTS = { itemsKey: 'threads', method: 'users.threads.list', query: 'x' } as const;
|
|
20
|
+
|
|
21
|
+
const parse = (r: { content: { type: string; text?: string }[] }) =>
|
|
22
|
+
JSON.parse(r.content[0].text as string);
|
|
23
|
+
|
|
24
|
+
describe('finalizeGmailSearch — ordering', () => {
|
|
25
|
+
it('sorts newest-first by internalDateIso', async () => {
|
|
26
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
|
|
27
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'c', 'a']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to date when internalDateIso is absent', async () => {
|
|
31
|
+
const raw = JSON.stringify({
|
|
32
|
+
threads: [
|
|
33
|
+
{ id: 'a', date: '2026-08-01T09:00:00-04:00' },
|
|
34
|
+
{ id: 'b', date: '2026-08-09T09:00:00-04:00' },
|
|
35
|
+
],
|
|
36
|
+
nextPageToken: '',
|
|
37
|
+
});
|
|
38
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
39
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('puts undated and unparseable items last, in their original order', async () => {
|
|
43
|
+
const raw = JSON.stringify({
|
|
44
|
+
threads: [
|
|
45
|
+
{ id: 'nodate' },
|
|
46
|
+
{ id: 'empty', internalDateIso: '' },
|
|
47
|
+
{ id: 'bad', internalDateIso: 'not-a-date' },
|
|
48
|
+
{ id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
49
|
+
],
|
|
50
|
+
nextPageToken: '',
|
|
51
|
+
});
|
|
52
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
53
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'nodate', 'empty', 'bad']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ignores a non-string date field', async () => {
|
|
57
|
+
const raw = JSON.stringify({
|
|
58
|
+
threads: [
|
|
59
|
+
{ id: 'weird', internalDateIso: 12345 },
|
|
60
|
+
{ id: 'dated', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
61
|
+
],
|
|
62
|
+
nextPageToken: '',
|
|
63
|
+
});
|
|
64
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
65
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['dated', 'weird']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('keeps equal timestamps in their original relative order', async () => {
|
|
69
|
+
const raw = JSON.stringify({
|
|
70
|
+
threads: [
|
|
71
|
+
{ id: 'first', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
72
|
+
{ id: 'second', internalDateIso: '2026-08-05T09:00:00-04:00' },
|
|
73
|
+
],
|
|
74
|
+
nextPageToken: '',
|
|
75
|
+
});
|
|
76
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), THREAD_OPTS));
|
|
77
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['first', 'second']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('sorts the messages key too', async () => {
|
|
81
|
+
const raw = JSON.stringify({
|
|
82
|
+
messages: [
|
|
83
|
+
{ id: 'a', internalDateIso: '2026-08-01T09:00:00-04:00' },
|
|
84
|
+
{ id: 'b', internalDateIso: '2026-08-12T09:00:00-04:00' },
|
|
85
|
+
],
|
|
86
|
+
nextPageToken: '',
|
|
87
|
+
});
|
|
88
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(raw), {
|
|
89
|
+
itemsKey: 'messages', method: 'users.messages.list', query: 'x',
|
|
90
|
+
}));
|
|
91
|
+
expect(out.messages.map((t: { id: string }) => t.id)).toEqual(['b', 'a']);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('finalizeGmailSearch — truncation metadata', () => {
|
|
96
|
+
const countReply = (ids: number, more: boolean) => JSON.stringify({
|
|
97
|
+
threads: Array.from({ length: ids }, (_, i) => ({ id: `t${i}` })),
|
|
98
|
+
...(more ? { nextPageToken: 'more' } : {}),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('omits every truncation field when the result set is complete', async () => {
|
|
102
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('')), THREAD_OPTS));
|
|
103
|
+
expect(out).not.toHaveProperty('truncated');
|
|
104
|
+
expect(out).not.toHaveProperty('returned');
|
|
105
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
106
|
+
expect(out).not.toHaveProperty('warning');
|
|
107
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('omits them when gog reports no nextPageToken field at all', async () => {
|
|
111
|
+
const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[{"id":"a"}]}'), THREAD_OPTS));
|
|
112
|
+
expect(out).not.toHaveProperty('truncated');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('reports an EXACT total when the count probe reaches the end', async () => {
|
|
116
|
+
vi.mocked(runner.run).mockResolvedValue(countReply(21, false));
|
|
117
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
|
|
118
|
+
itemsKey: 'threads', method: 'users.threads.list', query: 'invoice', account: 'me@x.com',
|
|
119
|
+
}));
|
|
120
|
+
expect(out.truncated).toBe(true);
|
|
121
|
+
expect(out.returned).toBe(3);
|
|
122
|
+
expect(out.totalMatches).toBe(21);
|
|
123
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
124
|
+
expect(out.warning).toBe(
|
|
125
|
+
'INCOMPLETE RESULT SET: returned 3 of 21 matches. Do not report an absence of results ' +
|
|
126
|
+
'based on this response. Page with nextPageToken or narrow the query.',
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('reports a LOWER BOUND when the count probe fills its page', async () => {
|
|
131
|
+
vi.mocked(runner.run).mockResolvedValue(countReply(500, true));
|
|
132
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
133
|
+
expect(out.totalMatchesAtLeast).toBe(500);
|
|
134
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
135
|
+
expect(out.warning).toBe(
|
|
136
|
+
'INCOMPLETE RESULT SET: returned 3 of at least 500 matches. Do not report an absence of ' +
|
|
137
|
+
'results based on this response. Page with nextPageToken or narrow the query.',
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('counts bare ids for one maximal page, scoped to the matching items key', async () => {
|
|
142
|
+
vi.mocked(runner.run).mockResolvedValue('{"messages":[{"id":"a"}]}');
|
|
143
|
+
const raw = JSON.stringify({ messages: [{ id: 'a' }], nextPageToken: 'tok' });
|
|
144
|
+
await finalizeGmailSearch(rawTextResult(raw), {
|
|
145
|
+
itemsKey: 'messages', method: 'users.messages.list', query: 'invoice', account: 'me@x.com',
|
|
146
|
+
});
|
|
147
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
148
|
+
['api', 'call', 'gmail', 'v1', 'users.messages.list',
|
|
149
|
+
`--params=${JSON.stringify({ userId: 'me', q: 'invoice', maxResults: 500, fields: 'messages/id,nextPageToken' })}`],
|
|
150
|
+
{ account: 'me@x.com' },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('still warns, without a count, when the count probe fails', async () => {
|
|
155
|
+
vi.mocked(runner.run).mockRejectedValue(new Error('nope'));
|
|
156
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
157
|
+
expect(out.truncated).toBe(true);
|
|
158
|
+
expect(out.returned).toBe(3);
|
|
159
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
160
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
161
|
+
expect(out.warning).toBe(
|
|
162
|
+
'INCOMPLETE RESULT SET: returned 3 matches and MORE EXIST beyond this page. Do not report ' +
|
|
163
|
+
'an absence of results based on this response. Page with nextPageToken or narrow the query.',
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('omits the count when the probe output is not JSON', async () => {
|
|
168
|
+
vi.mocked(runner.run).mockResolvedValue('not json');
|
|
169
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
170
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('omits the count when the probe returns no items array', async () => {
|
|
174
|
+
vi.mocked(runner.run).mockResolvedValue('{"nextPageToken":"x"}');
|
|
175
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
176
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
177
|
+
expect(out).not.toHaveProperty('totalMatchesAtLeast');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('treats an empty nextPageToken on the probe as the end of the set', async () => {
|
|
181
|
+
vi.mocked(runner.run).mockResolvedValue('{"threads":[{"id":"a"}],"nextPageToken":""}');
|
|
182
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), THREAD_OPTS));
|
|
183
|
+
expect(out.totalMatches).toBe(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('skips the count probe when the query gog ran cannot be reproduced', async () => {
|
|
187
|
+
const out = parse(await finalizeGmailSearch(rawTextResult(THREADS('tok')), {
|
|
188
|
+
...THREAD_OPTS, queryIsExact: false,
|
|
189
|
+
}));
|
|
190
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
191
|
+
expect(out.truncated).toBe(true);
|
|
192
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('finalizeGmailSearch — pass-through', () => {
|
|
197
|
+
it('passes an error result through untouched', async () => {
|
|
198
|
+
const err = errorResult('Error: boom');
|
|
199
|
+
expect(await finalizeGmailSearch(err, THREAD_OPTS)).toBe(err);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('passes non-JSON output through untouched', async () => {
|
|
203
|
+
const text = rawTextResult('No results');
|
|
204
|
+
expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('passes JSON without the expected items array through untouched', async () => {
|
|
208
|
+
const text = rawTextResult('{"somethingElse":1}');
|
|
209
|
+
expect(await finalizeGmailSearch(text, THREAD_OPTS)).toBe(text);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('passes a result with no text content through untouched', async () => {
|
|
213
|
+
const empty = { content: [] } as unknown as Parameters<typeof finalizeGmailSearch>[0];
|
|
214
|
+
expect(await finalizeGmailSearch(empty, THREAD_OPTS)).toBe(empty);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('handles an empty result set without adding truncation fields', async () => {
|
|
218
|
+
const out = parse(await finalizeGmailSearch(rawTextResult('{"threads":[],"nextPageToken":""}'), THREAD_OPTS));
|
|
219
|
+
expect(out.threads).toEqual([]);
|
|
220
|
+
expect(out).not.toHaveProperty('truncated');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('fetchGmailPages', () => {
|
|
225
|
+
const page = (ids: string[], token?: string) => rawTextResult(JSON.stringify({
|
|
226
|
+
threads: ids.map((id) => ({ id })),
|
|
227
|
+
...(token === undefined ? {} : { nextPageToken: token }),
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
it('merges pages and drops the cursor when it reaches the end', async () => {
|
|
231
|
+
const runPage = vi.fn()
|
|
232
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
233
|
+
.mockResolvedValueOnce(page(['b'], 'T2'))
|
|
234
|
+
.mockResolvedValueOnce(page(['c']));
|
|
235
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
|
|
236
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a', 'b', 'c']);
|
|
237
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
238
|
+
expect(runPage.mock.calls.map((c) => c[0])).toEqual([undefined, 'T1', 'T2']);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('treats an empty-string cursor as the end', async () => {
|
|
242
|
+
const runPage = vi.fn().mockResolvedValueOnce(page(['a'], ''));
|
|
243
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 5, undefined)).content[0].text as string);
|
|
244
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
245
|
+
expect(runPage).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('keeps the cursor when the page cap is reached first', async () => {
|
|
249
|
+
const runPage = vi.fn()
|
|
250
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
251
|
+
.mockResolvedValueOnce(page(['b'], 'T2'));
|
|
252
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 2, undefined)).content[0].text as string);
|
|
253
|
+
expect(out.threads).toHaveLength(2);
|
|
254
|
+
expect(out.nextPageToken).toBe('T2');
|
|
255
|
+
expect(runPage).toHaveBeenCalledTimes(2);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('starts from a caller-supplied cursor', async () => {
|
|
259
|
+
const runPage = vi.fn().mockResolvedValue(page(['a']));
|
|
260
|
+
await fetchGmailPages(runPage, 'threads', 3, 'START');
|
|
261
|
+
expect(runPage).toHaveBeenCalledWith('START');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('surfaces a failure on the FIRST page as-is', async () => {
|
|
265
|
+
const err = errorResult('Error: boom');
|
|
266
|
+
const runPage = vi.fn().mockResolvedValue(err);
|
|
267
|
+
expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(err);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('keeps pages already collected when a LATER page fails, still marked truncated', async () => {
|
|
271
|
+
const runPage = vi.fn()
|
|
272
|
+
.mockResolvedValueOnce(page(['a'], 'T1'))
|
|
273
|
+
.mockResolvedValueOnce(errorResult('Error: boom'));
|
|
274
|
+
const out = JSON.parse((await fetchGmailPages(runPage, 'threads', 3, undefined)).content[0].text as string);
|
|
275
|
+
expect(out.threads.map((t: { id: string }) => t.id)).toEqual(['a']);
|
|
276
|
+
expect(out.nextPageToken).toBe('T1');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('bails on output it cannot parse or that has no items array', async () => {
|
|
280
|
+
for (const bad of [rawTextResult('not json'), rawTextResult('"scalar"'), rawTextResult('{"other":1}')]) {
|
|
281
|
+
const runPage = vi.fn().mockResolvedValue(bad);
|
|
282
|
+
expect(await fetchGmailPages(runPage, 'threads', 3, undefined)).toBe(bad);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createTestHarness } from '@chrischall/mcp-utils/test';
|
|
3
|
+
import { BASE_TOOL_REGISTRARS } from '../src/server.js';
|
|
4
|
+
|
|
5
|
+
// The bug this guards against was a NAME mismatch: the cursor was called `page`
|
|
6
|
+
// while every response reports `nextPageToken`, and MCP inputs are zod objects
|
|
7
|
+
// that silently strip unknown keys — so a client guessing the name from the
|
|
8
|
+
// response got page 1 forever, with no error. Prose that steers a caller back
|
|
9
|
+
// to the deprecated alias re-creates exactly that trap, one tool at a time.
|
|
10
|
+
|
|
11
|
+
type ToolDef = { name: string; description?: string; inputSchema?: { properties?: Record<string, unknown> } };
|
|
12
|
+
|
|
13
|
+
async function allTools(): Promise<ToolDef[]> {
|
|
14
|
+
const harness = await createTestHarness((server) => {
|
|
15
|
+
for (const register of BASE_TOOL_REGISTRARS) register(server);
|
|
16
|
+
});
|
|
17
|
+
const { tools } = await harness.client.listTools();
|
|
18
|
+
await harness.close();
|
|
19
|
+
return tools as ToolDef[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('page-cursor contract across every base tool', () => {
|
|
23
|
+
it('never tells a caller to pass the cursor as the deprecated `page`', async () => {
|
|
24
|
+
// The alias as its own token: `page` closed immediately, which `pageToken`
|
|
25
|
+
// can never match. A looser pattern matches the correct name's prefix and
|
|
26
|
+
// fails on a description that is already right.
|
|
27
|
+
const offenders = (await allTools())
|
|
28
|
+
.filter((t) => /`page`|\bas page\b/i.test(t.description ?? ''))
|
|
29
|
+
.map((t) => t.name);
|
|
30
|
+
expect(offenders).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('always offers the `page` alias wherever `pageToken` is accepted', async () => {
|
|
34
|
+
const mismatched = (await allTools())
|
|
35
|
+
.filter((t) => {
|
|
36
|
+
const props = t.inputSchema?.properties ?? {};
|
|
37
|
+
return 'pageToken' in props && !('page' in props);
|
|
38
|
+
})
|
|
39
|
+
.map((t) => t.name);
|
|
40
|
+
expect(mismatched).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('names the cursor after the response field wherever one is paginated', async () => {
|
|
44
|
+
const tools = await allTools();
|
|
45
|
+
const withCursor = tools.filter((t) => 'pageToken' in (t.inputSchema?.properties ?? {}));
|
|
46
|
+
// Guards the audit itself: if this ever drops to zero the two tests above
|
|
47
|
+
// pass vacuously and the contract stops being checked at all.
|
|
48
|
+
expect(withCursor.length).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { rawTextResult, errorResult } from '@chrischall/mcp-utils';
|
|
3
|
+
import {
|
|
4
|
+
stripConsumedPageToken,
|
|
5
|
+
annotateTruncatedList,
|
|
6
|
+
truncationWarning,
|
|
7
|
+
hasMorePages,
|
|
8
|
+
} from '../src/pagination.js';
|
|
9
|
+
|
|
10
|
+
describe('stripConsumedPageToken', () => {
|
|
11
|
+
it('removes an exhausted cursor so the key means "another page exists"', () => {
|
|
12
|
+
expect(stripConsumedPageToken('{"threads":[{"id":"a"}],"nextPageToken":""}'))
|
|
13
|
+
.toBe('{"threads":[{"id":"a"}]}');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('keeps a live cursor untouched, byte for byte', () => {
|
|
17
|
+
const live = '{"threads":[{"id":"a"}],"nextPageToken":"tok"}';
|
|
18
|
+
expect(stripConsumedPageToken(live)).toBe(live);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('preserves --pretty indentation when it has to re-serialize', () => {
|
|
22
|
+
const pretty = '{\n "threads": [],\n "nextPageToken": ""\n}';
|
|
23
|
+
expect(stripConsumedPageToken(pretty)).toBe('{\n "threads": []\n}');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('treats a tab indent as two spaces, matching the timestamp seam', () => {
|
|
27
|
+
expect(stripConsumedPageToken('{\n\t"a": 1,\n\t"nextPageToken": ""\n}'))
|
|
28
|
+
.toBe('{\n "a": 1\n}');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('leaves a payload with no cursor field alone', () => {
|
|
32
|
+
const plain = '{"threads":[]}';
|
|
33
|
+
expect(stripConsumedPageToken(plain)).toBe(plain);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('only touches the top level — a nested cursor is another resource\'s', () => {
|
|
37
|
+
const nested = '{"thread":{"nextPageToken":""}}';
|
|
38
|
+
expect(stripConsumedPageToken(nested)).toBe(nested);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('passes through non-JSON, empty, array and scalar output untouched', () => {
|
|
42
|
+
for (const text of ['No results', '', ' ', '[{"nextPageToken":""}]', '"a string"', '{not json']) {
|
|
43
|
+
expect(stripConsumedPageToken(text)).toBe(text);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('passes through a JSON null without dereferencing it', () => {
|
|
48
|
+
expect(stripConsumedPageToken('null')).toBe('null');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('truncationWarning', () => {
|
|
53
|
+
it('names an exact total when one is known', () => {
|
|
54
|
+
expect(truncationWarning(3, { total: 21 })).toContain('returned 3 of 21 matches');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('says "at least" for a lower bound', () => {
|
|
58
|
+
expect(truncationWarning(3, { atLeast: 500 })).toContain('returned 3 of at least 500 matches');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('still forbids a negative conclusion when no count is available', () => {
|
|
62
|
+
const w = truncationWarning(3, {});
|
|
63
|
+
expect(w).toContain('returned 3 matches and MORE EXIST beyond this page');
|
|
64
|
+
expect(w).toContain('Do not report an absence of results');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('hasMorePages', () => {
|
|
69
|
+
it('is true only for a live cursor', () => {
|
|
70
|
+
expect(hasMorePages({ nextPageToken: 'tok' })).toBe(true);
|
|
71
|
+
expect(hasMorePages({ nextPageToken: '' })).toBe(false);
|
|
72
|
+
expect(hasMorePages({})).toBe(false);
|
|
73
|
+
expect(hasMorePages({ nextPageToken: 7 })).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('annotateTruncatedList', () => {
|
|
78
|
+
it('adds the block when a live cursor remains', () => {
|
|
79
|
+
const out = JSON.parse(annotateTruncatedList(
|
|
80
|
+
rawTextResult('{"events":[{"id":"a"},{"id":"b"}],"nextPageToken":"tok"}'), 'events',
|
|
81
|
+
).content[0].text as string);
|
|
82
|
+
expect(out.truncated).toBe(true);
|
|
83
|
+
expect(out.returned).toBe(2);
|
|
84
|
+
expect(out.warning).toContain('INCOMPLETE RESULT SET');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns the result untouched when the set is complete', () => {
|
|
88
|
+
const complete = rawTextResult('{"events":[{"id":"a"}]}');
|
|
89
|
+
expect(annotateTruncatedList(complete, 'events')).toBe(complete);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('passes errors, non-JSON, missing arrays and empty content through untouched', () => {
|
|
93
|
+
const err = errorResult('boom');
|
|
94
|
+
expect(annotateTruncatedList(err, 'events')).toBe(err);
|
|
95
|
+
const text = rawTextResult('No results');
|
|
96
|
+
expect(annotateTruncatedList(text, 'events')).toBe(text);
|
|
97
|
+
const other = rawTextResult('{"somethingElse":1,"nextPageToken":"tok"}');
|
|
98
|
+
expect(annotateTruncatedList(other, 'events')).toBe(other);
|
|
99
|
+
const empty = { content: [] } as unknown as Parameters<typeof annotateTruncatedList>[0];
|
|
100
|
+
expect(annotateTruncatedList(empty, 'events')).toBe(empty);
|
|
101
|
+
});
|
|
102
|
+
});
|
package/tests/tools/auth.test.ts
CHANGED
|
@@ -124,6 +124,35 @@ describe('gog_auth_add', () => {
|
|
|
124
124
|
);
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
// gog 0.37.0's Connected Sheets reads need bigquery.readonly, which no
|
|
128
|
+
// `services` selection covers. --force-consent is not optional alongside it:
|
|
129
|
+
// Google re-prompts for a NEW scope only when consent is forced, so without
|
|
130
|
+
// it the grant can come back missing the scope AND reporting success.
|
|
131
|
+
it('passes --extra-scopes with --force-consent', async () => {
|
|
132
|
+
vi.mocked(runner.run).mockResolvedValue('Authorization successful');
|
|
133
|
+
const harness = await setupHandlers();
|
|
134
|
+
await harness.callTool('gog_auth_add', {
|
|
135
|
+
email: 'user@gmail.com',
|
|
136
|
+
services: 'sheets',
|
|
137
|
+
extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
|
|
138
|
+
});
|
|
139
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
140
|
+
['auth', 'add', 'user@gmail.com', '--services', 'sheets',
|
|
141
|
+
'--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly', '--force-consent'],
|
|
142
|
+
{ interactive: true, timeout: 300_000 },
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('does not force consent when no extra scopes are asked for', async () => {
|
|
147
|
+
vi.mocked(runner.run).mockResolvedValue('Authorization successful');
|
|
148
|
+
const harness = await setupHandlers();
|
|
149
|
+
await harness.callTool('gog_auth_add', { email: 'user@gmail.com' });
|
|
150
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
151
|
+
['auth', 'add', 'user@gmail.com', '--services', 'all'],
|
|
152
|
+
{ interactive: true, timeout: 300_000 },
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
127
156
|
it('returns error text on failure', async () => {
|
|
128
157
|
vi.mocked(runner.run).mockRejectedValue(new Error('Auth cancelled by user'));
|
|
129
158
|
const harness = await setupHandlers();
|
|
@@ -215,6 +244,21 @@ describe('gog_auth_add_url', () => {
|
|
|
215
244
|
);
|
|
216
245
|
});
|
|
217
246
|
|
|
247
|
+
it('appends --extra-scopes after the service scopes', async () => {
|
|
248
|
+
vi.mocked(runner.run).mockResolvedValue('{"auth_url":"https://x"}');
|
|
249
|
+
const harness = await setupHandlers();
|
|
250
|
+
await harness.callTool('gog_auth_add_url', {
|
|
251
|
+
email: 'user@gmail.com',
|
|
252
|
+
services: 'sheets',
|
|
253
|
+
extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
|
|
254
|
+
});
|
|
255
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
256
|
+
['auth', 'add', 'user@gmail.com', '--remote', '--step', '1', '--services', 'sheets', '--force-consent',
|
|
257
|
+
'--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
|
|
258
|
+
{ redactMode: 'tokens' },
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
218
262
|
it('returns error text on failure', async () => {
|
|
219
263
|
vi.mocked(runner.run).mockRejectedValue(new Error('client not configured'));
|
|
220
264
|
const harness = await setupHandlers();
|
|
@@ -252,6 +296,22 @@ describe('gog_auth_add_complete', () => {
|
|
|
252
296
|
);
|
|
253
297
|
});
|
|
254
298
|
|
|
299
|
+
it('carries the same --extra-scopes as step 1', async () => {
|
|
300
|
+
vi.mocked(runner.run).mockResolvedValue('{"stored":true}');
|
|
301
|
+
const harness = await setupHandlers();
|
|
302
|
+
await harness.callTool('gog_auth_add_complete', {
|
|
303
|
+
email: 'user@gmail.com',
|
|
304
|
+
redirectUrl: 'http://127.0.0.1/cb?code=c&state=s',
|
|
305
|
+
services: 'sheets',
|
|
306
|
+
extraScopes: 'https://www.googleapis.com/auth/bigquery.readonly',
|
|
307
|
+
});
|
|
308
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
309
|
+
['auth', 'add', 'user@gmail.com', '--remote', '--step', '2', '--auth-url',
|
|
310
|
+
'http://127.0.0.1/cb?code=c&state=s', '--services', 'sheets', '--force-consent',
|
|
311
|
+
'--extra-scopes=https://www.googleapis.com/auth/bigquery.readonly'],
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
|
|
255
315
|
it('returns error text on failure (e.g. expired state)', async () => {
|
|
256
316
|
vi.mocked(runner.run).mockRejectedValue(new Error('no matching manual auth state'));
|
|
257
317
|
const harness = await setupHandlers();
|
|
@@ -17,23 +17,50 @@ describe('gog_calendar_events', () => {
|
|
|
17
17
|
expect(runner.run).toHaveBeenCalledWith(['calendar', 'events'], { account: undefined });
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// The window flags are pinned as SEPARATE cases on purpose: gog >= 0.36.0
|
|
21
|
+
// (openclaw/gogcli#981) rejects a fixed preset combined with from/to/days,
|
|
22
|
+
// and days combined with to, so one test passing them all at once would
|
|
23
|
+
// assert an arg array gog refuses to run.
|
|
24
|
+
it('appends calendarId and an explicit from/to range', async () => {
|
|
21
25
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
22
26
|
const harness = await setupHandlers();
|
|
23
27
|
await harness.callTool('gog_calendar_events', {
|
|
24
28
|
calendarId: 'primary',
|
|
25
29
|
from: '2026-01-01',
|
|
26
30
|
to: '2026-01-31',
|
|
27
|
-
today: true,
|
|
28
31
|
query: 'standup',
|
|
29
32
|
all: true,
|
|
30
33
|
});
|
|
31
34
|
expect(runner.run).toHaveBeenCalledWith(
|
|
32
|
-
['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--
|
|
35
|
+
['calendar', 'events', 'primary', '--from=2026-01-01', '--to=2026-01-31', '--query=standup', '--all'],
|
|
33
36
|
{ account: undefined },
|
|
34
37
|
);
|
|
35
38
|
});
|
|
36
39
|
|
|
40
|
+
it('appends --today on its own', async () => {
|
|
41
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
42
|
+
const harness = await setupHandlers();
|
|
43
|
+
await harness.callTool('gog_calendar_events', { today: true });
|
|
44
|
+
expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--today'], { account: undefined });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('anchors --days at --from when both are given', async () => {
|
|
48
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
49
|
+
const harness = await setupHandlers();
|
|
50
|
+
await harness.callTool('gog_calendar_events', { from: '2026-09-25', days: 5 });
|
|
51
|
+
expect(runner.run).toHaveBeenCalledWith(
|
|
52
|
+
['calendar', 'events', '--from=2026-09-25', '--days=5'],
|
|
53
|
+
{ account: undefined },
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('passes --days alone as a today-anchored window', async () => {
|
|
58
|
+
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
59
|
+
const harness = await setupHandlers();
|
|
60
|
+
await harness.callTool('gog_calendar_events', { days: 7 });
|
|
61
|
+
expect(runner.run).toHaveBeenCalledWith(['calendar', 'events', '--days=7'], { account: undefined });
|
|
62
|
+
});
|
|
63
|
+
|
|
37
64
|
it('repeats --event-types for each requested type', async () => {
|
|
38
65
|
vi.mocked(runner.run).mockResolvedValue('{}');
|
|
39
66
|
const harness = await setupHandlers();
|
|
@@ -379,3 +406,54 @@ describe('gog_calendar_run', () => {
|
|
|
379
406
|
});
|
|
380
407
|
});
|
|
381
408
|
|
|
409
|
+
|
|
410
|
+
describe('gog_calendar_events — pagination (previously absent entirely)', () => {
|
|
411
|
+
// gog defaults this command to --max=10 and the tool exposed NEITHER max nor
|
|
412
|
+
// a cursor, so a wide date range silently returned 10 events with a live
|
|
413
|
+
// token the caller could not use. Measured live: 2025-01-01..2026-08-01 gave
|
|
414
|
+
// 10 of 12.
|
|
415
|
+
it('passes --max and --page through to gog', async () => {
|
|
416
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
417
|
+
const harness = await setupHandlers();
|
|
418
|
+
await harness.callTool('gog_calendar_events', { max: 100, pageToken: 'CURSOR' });
|
|
419
|
+
const args = vi.mocked(runner.run).mock.calls[0][0] as string[];
|
|
420
|
+
expect(args).toContain('--max=100');
|
|
421
|
+
expect(args).toContain('--page=CURSOR');
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('accepts the deprecated page alias', async () => {
|
|
425
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
426
|
+
const harness = await setupHandlers();
|
|
427
|
+
await harness.callTool('gog_calendar_events', { page: 'CURSOR' });
|
|
428
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--page=CURSOR');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('keeps --all meaning ALL CALENDARS, not all pages', async () => {
|
|
432
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[]}');
|
|
433
|
+
const harness = await setupHandlers();
|
|
434
|
+
await harness.callTool('gog_calendar_events', { all: true });
|
|
435
|
+
expect(vi.mocked(runner.run).mock.calls[0][0]).toContain('--all');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('marks a capped range truncated, with no fabricated total', async () => {
|
|
439
|
+
vi.mocked(runner.run).mockResolvedValue(JSON.stringify({
|
|
440
|
+
events: [{ id: 'e1' }, { id: 'e2' }],
|
|
441
|
+
nextPageToken: 'MORE',
|
|
442
|
+
}));
|
|
443
|
+
const harness = await setupHandlers();
|
|
444
|
+
const out = JSON.parse((await harness.callTool('gog_calendar_events',
|
|
445
|
+
{ from: '2025-01-01', to: '2026-08-01' })).content[0].text as string);
|
|
446
|
+
expect(out.truncated).toBe(true);
|
|
447
|
+
expect(out.returned).toBe(2);
|
|
448
|
+
expect(out).not.toHaveProperty('totalMatches');
|
|
449
|
+
expect(out.warning).toContain('INCOMPLETE RESULT SET: returned 2 matches and MORE EXIST');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('leaves a complete range unannotated', async () => {
|
|
453
|
+
vi.mocked(runner.run).mockResolvedValue('{"events":[{"id":"e1"}],"nextPageToken":""}');
|
|
454
|
+
const harness = await setupHandlers();
|
|
455
|
+
const out = JSON.parse((await harness.callTool('gog_calendar_events', {})).content[0].text as string);
|
|
456
|
+
expect(out).not.toHaveProperty('truncated');
|
|
457
|
+
expect(out).not.toHaveProperty('nextPageToken');
|
|
458
|
+
});
|
|
459
|
+
});
|