@oceanum/oceanumlab 4.7.1 → 4.7.2
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/lib/DatameshWidget.js +14 -5
- package/lib/StoredNotebooks.d.ts +9 -5
- package/lib/StoredNotebooks.js +49 -21
- package/lib/auth/serverAuth.js +1 -0
- package/lib/auth/startSignIn.d.ts +11 -0
- package/lib/auth/startSignIn.js +16 -0
- package/lib/auth/tokens.d.ts +7 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +8 -6
- package/lib/share/client.d.ts +84 -0
- package/lib/share/client.js +205 -0
- package/lib/share/events.d.ts +7 -0
- package/lib/share/events.js +9 -0
- package/lib/share/notebook.d.ts +184 -0
- package/lib/share/notebook.js +277 -0
- package/lib/share/oceanumOnly.d.ts +49 -0
- package/lib/share/oceanumOnly.js +90 -0
- package/lib/share/plugin.d.ts +21 -0
- package/lib/share/plugin.js +883 -0
- package/lib/share/widgets.d.ts +25 -0
- package/lib/share/widgets.js +154 -0
- package/package.json +5 -1
- package/src/DatameshWidget.tsx +21 -2
- package/src/StoredNotebooks.tsx +60 -20
- package/src/__tests__/OceanumPanel.spec.tsx +60 -4
- package/src/__tests__/StoredNotebooks.spec.tsx +157 -0
- package/src/__tests__/oceanumOnly.spec.ts +193 -0
- package/src/__tests__/shareClient.spec.ts +314 -0
- package/src/__tests__/shareNotebook.spec.ts +451 -0
- package/src/__tests__/sharePlugin.spec.ts +426 -0
- package/src/auth/serverAuth.ts +1 -0
- package/src/auth/startSignIn.ts +23 -0
- package/src/auth/tokens.ts +7 -0
- package/src/index.ts +8 -6
- package/src/share/client.ts +288 -0
- package/src/share/events.ts +10 -0
- package/src/share/notebook.ts +416 -0
- package/src/share/oceanumOnly.ts +126 -0
- package/src/share/plugin.ts +1096 -0
- package/src/share/widgets.ts +217 -0
- package/style/index.css +111 -16
- package/lib/specStore.d.ts +0 -37
- package/lib/specStore.js +0 -57
- package/src/__tests__/specStore.spec.ts +0 -126
- package/src/specStore.ts +0 -82
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import type { INotebookContent } from '@jupyterlab/nbformat';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildSpecBody,
|
|
5
|
+
isShareableEmail,
|
|
6
|
+
isSpecId,
|
|
7
|
+
nameFromPath,
|
|
8
|
+
notebookFromRecord,
|
|
9
|
+
parseShareEmails,
|
|
10
|
+
parseTimestamp,
|
|
11
|
+
partitionSummaries,
|
|
12
|
+
readLink,
|
|
13
|
+
reportForShareFailures,
|
|
14
|
+
sanitizeName,
|
|
15
|
+
shareLink,
|
|
16
|
+
uniqueNotebookPath,
|
|
17
|
+
withoutTrust
|
|
18
|
+
} from '../share/notebook';
|
|
19
|
+
|
|
20
|
+
const SPECS = 'https://specs.example.com';
|
|
21
|
+
const ID = '6f1c1a52-4b8e-4c0f-9a57-1d2e3f4a5b6c';
|
|
22
|
+
|
|
23
|
+
function notebook(
|
|
24
|
+
outputText = 'hello',
|
|
25
|
+
metadata: object = {}
|
|
26
|
+
): INotebookContent {
|
|
27
|
+
return {
|
|
28
|
+
nbformat: 4,
|
|
29
|
+
nbformat_minor: 5,
|
|
30
|
+
metadata: {
|
|
31
|
+
kernelspec: { name: 'python', display_name: 'Python' },
|
|
32
|
+
...metadata
|
|
33
|
+
},
|
|
34
|
+
cells: [
|
|
35
|
+
{ cell_type: 'markdown', source: '# Title', metadata: {} },
|
|
36
|
+
{
|
|
37
|
+
cell_type: 'code',
|
|
38
|
+
source: 'print(1)',
|
|
39
|
+
metadata: {},
|
|
40
|
+
execution_count: 3,
|
|
41
|
+
outputs: [{ output_type: 'stream', name: 'stdout', text: outputText }]
|
|
42
|
+
}
|
|
43
|
+
]
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('isSpecId', () => {
|
|
48
|
+
it('accepts UUIDs only', () => {
|
|
49
|
+
expect(isSpecId(ID)).toBe(true);
|
|
50
|
+
expect(isSpecId(ID.toUpperCase())).toBe(true);
|
|
51
|
+
expect(isSpecId(`${ID}/permissions`)).toBe(false);
|
|
52
|
+
expect(isSpecId('../x')).toBe(false);
|
|
53
|
+
expect(isSpecId('')).toBe(false);
|
|
54
|
+
expect(isSpecId(42)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('readLink', () => {
|
|
59
|
+
it('reads a link for the same spec store', () => {
|
|
60
|
+
expect(
|
|
61
|
+
readLink({ spec_id: ID, specs_url: SPECS, description: 'd' }, SPECS)
|
|
62
|
+
).toEqual({
|
|
63
|
+
spec_id: ID,
|
|
64
|
+
specs_url: SPECS,
|
|
65
|
+
description: 'd'
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('ignores links to another spec store or with a bad id', () => {
|
|
70
|
+
expect(
|
|
71
|
+
readLink({ spec_id: ID, specs_url: 'https://specs.dev' }, SPECS)
|
|
72
|
+
).toBeNull();
|
|
73
|
+
expect(readLink({ spec_id: 'nope', specs_url: SPECS }, SPECS)).toBeNull();
|
|
74
|
+
expect(readLink(undefined, SPECS)).toBeNull();
|
|
75
|
+
expect(readLink([ID], SPECS)).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('buildSpecBody', () => {
|
|
80
|
+
it('uses the file name and drops the link from the stored notebook', () => {
|
|
81
|
+
const content = notebook('hi', {
|
|
82
|
+
oceanum: { spec_id: ID, specs_url: SPECS, description: 'Wave stats' }
|
|
83
|
+
});
|
|
84
|
+
const result = buildSpecBody(content, 'Oceanum/Waves (1).ipynb', SPECS);
|
|
85
|
+
expect(result.ok).toBe(true);
|
|
86
|
+
if (!result.ok) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
expect(result.strippedOutputs).toBe(false);
|
|
90
|
+
expect(result.body.name).toBe('Waves (1)');
|
|
91
|
+
expect(result.body.description).toBe('Wave stats');
|
|
92
|
+
expect(result.body.spec.metadata.oceanum).toBeUndefined();
|
|
93
|
+
expect(result.body.spec.metadata.kernelspec).toBeDefined();
|
|
94
|
+
expect(content.metadata.oceanum).toBeDefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('strips code-cell outputs when the body is over the limit', () => {
|
|
98
|
+
const content = notebook('x'.repeat(5000));
|
|
99
|
+
const result = buildSpecBody(content, 'big.ipynb', SPECS, 2000);
|
|
100
|
+
expect(result.ok).toBe(true);
|
|
101
|
+
if (!result.ok) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
expect(result.strippedOutputs).toBe(true);
|
|
105
|
+
expect(result.bytes).toBeLessThanOrEqual(2000);
|
|
106
|
+
const code = result.body.spec.cells[1] as { outputs: unknown[] };
|
|
107
|
+
expect(code.outputs).toEqual([]);
|
|
108
|
+
expect(
|
|
109
|
+
(result.body.spec.cells[1] as { execution_count: unknown })
|
|
110
|
+
.execution_count
|
|
111
|
+
).toBeNull();
|
|
112
|
+
expect(result.body.spec.cells[0]).toEqual(content.cells[0]);
|
|
113
|
+
// The input notebook is not modified.
|
|
114
|
+
expect((content.cells[1] as { outputs: unknown[] }).outputs).toHaveLength(
|
|
115
|
+
1
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('refuses a notebook that is too large even without outputs', () => {
|
|
120
|
+
const content = notebook();
|
|
121
|
+
content.cells[0].source = 'y'.repeat(5000);
|
|
122
|
+
const result = buildSpecBody(content, 'big.ipynb', SPECS, 2000);
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.bytes).toBeGreaterThan(2000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('measures UTF-8 bytes, not characters', () => {
|
|
128
|
+
const content = notebook('');
|
|
129
|
+
content.cells[0].source = '\u{1F30A}'.repeat(400); // 4 bytes each
|
|
130
|
+
const size = JSON.stringify({
|
|
131
|
+
name: 'n',
|
|
132
|
+
description: null,
|
|
133
|
+
spec: content
|
|
134
|
+
}).length;
|
|
135
|
+
const result = buildSpecBody(content, 'n.ipynb', SPECS, size + 100);
|
|
136
|
+
expect(result.ok).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('notebookFromRecord', () => {
|
|
141
|
+
const base = {
|
|
142
|
+
id: ID,
|
|
143
|
+
name: 'Waves',
|
|
144
|
+
modified: '2026-09-12T10:00:00',
|
|
145
|
+
creator: null as string | null
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
it('sets the oceanum link', () => {
|
|
149
|
+
const content = notebookFromRecord(
|
|
150
|
+
{ ...base, description: 'Wave stats', spec: notebook() },
|
|
151
|
+
SPECS
|
|
152
|
+
);
|
|
153
|
+
expect(content.metadata.oceanum).toEqual({
|
|
154
|
+
spec_id: ID,
|
|
155
|
+
specs_url: SPECS,
|
|
156
|
+
description: 'Wave stats'
|
|
157
|
+
});
|
|
158
|
+
expect(content.cells).toHaveLength(2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('replaces a stale link and omits an empty description', () => {
|
|
162
|
+
const spec = notebook('', { oceanum: { spec_id: 'old', specs_url: 'x' } });
|
|
163
|
+
const content = notebookFromRecord(
|
|
164
|
+
{ ...base, description: null, spec },
|
|
165
|
+
SPECS
|
|
166
|
+
);
|
|
167
|
+
expect(content.metadata.oceanum).toEqual({
|
|
168
|
+
spec_id: ID,
|
|
169
|
+
specs_url: SPECS
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects records that are not notebooks', () => {
|
|
174
|
+
for (const spec of [
|
|
175
|
+
{},
|
|
176
|
+
[],
|
|
177
|
+
null,
|
|
178
|
+
{ nbformat: 4, cells: {} },
|
|
179
|
+
{ nbformat: 3 }
|
|
180
|
+
]) {
|
|
181
|
+
expect(() =>
|
|
182
|
+
notebookFromRecord({ ...base, description: null, spec }, SPECS)
|
|
183
|
+
).toThrow(/not a valid notebook/);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('names and paths', () => {
|
|
189
|
+
it('derives the record name from a path', () => {
|
|
190
|
+
expect(nameFromPath('Oceanum/Waves.ipynb')).toBe('Waves');
|
|
191
|
+
expect(nameFromPath('a/b/c.IPYNB')).toBe('c');
|
|
192
|
+
expect(nameFromPath('.ipynb')).toBe('Untitled');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('sanitizes names to one path segment', () => {
|
|
196
|
+
expect(sanitizeName('a/b\\c')).toBe('a-b-c');
|
|
197
|
+
expect(sanitizeName(' Waves.ipynb ')).toBe('Waves');
|
|
198
|
+
expect(sanitizeName('..')).toBe('Untitled');
|
|
199
|
+
expect(sanitizeName(' ')).toBe('Untitled');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('picks a unique path in the Oceanum folder', () => {
|
|
203
|
+
expect(uniqueNotebookPath('Waves', [])).toBe('Oceanum/Waves.ipynb');
|
|
204
|
+
expect(uniqueNotebookPath('Waves', ['Waves.ipynb'])).toBe(
|
|
205
|
+
'Oceanum/Waves (1).ipynb'
|
|
206
|
+
);
|
|
207
|
+
expect(
|
|
208
|
+
uniqueNotebookPath('Wa/ves', [
|
|
209
|
+
'Wa-ves.ipynb',
|
|
210
|
+
'Wa-ves (1).ipynb',
|
|
211
|
+
'Other.ipynb'
|
|
212
|
+
])
|
|
213
|
+
).toBe('Oceanum/Wa-ves (2).ipynb');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('partitionSummaries', () => {
|
|
218
|
+
const item = (id: string, creator: string | null, modified: string) => ({
|
|
219
|
+
id,
|
|
220
|
+
name: id,
|
|
221
|
+
description: null as string | null,
|
|
222
|
+
creator,
|
|
223
|
+
modified
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('splits by creator, case-insensitively, newest first', () => {
|
|
227
|
+
const { mine, shared } = partitionSummaries(
|
|
228
|
+
[
|
|
229
|
+
item('a', 'me@example.com', '2026-01-01T00:00:00'),
|
|
230
|
+
item('b', 'other@example.com', '2026-03-01T00:00:00'),
|
|
231
|
+
item('c', 'Me@Example.com', '2026-02-01T00:00:00'),
|
|
232
|
+
item('d', null, '2026-01-02T00:00:00')
|
|
233
|
+
],
|
|
234
|
+
'me@example.com'
|
|
235
|
+
);
|
|
236
|
+
expect(mine.map(i => i.id)).toEqual(['c', 'a']);
|
|
237
|
+
expect(shared.map(i => i.id)).toEqual(['b', 'd']);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('treats everything as shared when there is no email to compare', () => {
|
|
241
|
+
const { mine, shared } = partitionSummaries(
|
|
242
|
+
[item('a', 'me@example.com', '2026-01-01T00:00:00')],
|
|
243
|
+
null
|
|
244
|
+
);
|
|
245
|
+
expect(mine).toEqual([]);
|
|
246
|
+
expect(shared.map(i => i.id)).toEqual(['a']);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('parseTimestamp', () => {
|
|
251
|
+
it('treats naive timestamps as UTC', () => {
|
|
252
|
+
expect(parseTimestamp('2026-09-12T10:00:00')?.toISOString()).toBe(
|
|
253
|
+
'2026-09-12T10:00:00.000Z'
|
|
254
|
+
);
|
|
255
|
+
expect(parseTimestamp('2026-09-12T10:00:00+02:00')?.toISOString()).toBe(
|
|
256
|
+
'2026-09-12T08:00:00.000Z'
|
|
257
|
+
);
|
|
258
|
+
expect(parseTimestamp('garbage')).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe('isShareableEmail', () => {
|
|
263
|
+
it('accepts plain addresses and refuses glob characters', () => {
|
|
264
|
+
expect(isShareableEmail('b@example.com')).toBe(true);
|
|
265
|
+
expect(isShareableEmail('*@example.com')).toBe(false);
|
|
266
|
+
expect(isShareableEmail('b@exam?le.com')).toBe(false);
|
|
267
|
+
expect(isShareableEmail('b@[example].com')).toBe(false);
|
|
268
|
+
expect(isShareableEmail('not an email')).toBe(false);
|
|
269
|
+
expect(isShareableEmail('')).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe('shareLink', () => {
|
|
274
|
+
it('keeps lab/index.html on a JupyterLite page, from an absolute or path base URL', () => {
|
|
275
|
+
const expected = `https://notebook.example.com/sub/lab/index.html?oceanum-notebook=${ID}`;
|
|
276
|
+
expect(
|
|
277
|
+
shareLink(
|
|
278
|
+
'https://notebook.example.com/sub/',
|
|
279
|
+
'https://notebook.example.com/sub/lab/index.html',
|
|
280
|
+
ID
|
|
281
|
+
)
|
|
282
|
+
).toBe(expected);
|
|
283
|
+
expect(
|
|
284
|
+
shareLink(
|
|
285
|
+
'/sub/',
|
|
286
|
+
'https://notebook.example.com/sub/lab/index.html?x=1',
|
|
287
|
+
ID
|
|
288
|
+
)
|
|
289
|
+
).toBe(expected);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('links to lab on a JupyterLab server, which serves no index.html', () => {
|
|
293
|
+
expect(
|
|
294
|
+
shareLink('http://localhost:8888/', 'http://localhost:8888/lab', ID)
|
|
295
|
+
).toBe(`http://localhost:8888/lab?oceanum-notebook=${ID}`);
|
|
296
|
+
expect(
|
|
297
|
+
shareLink(
|
|
298
|
+
'https://hub.example.com/user/me/',
|
|
299
|
+
'https://hub.example.com/user/me/lab/tree/Oceanum/Waves.ipynb',
|
|
300
|
+
ID
|
|
301
|
+
)
|
|
302
|
+
).toBe(`https://hub.example.com/user/me/lab?oceanum-notebook=${ID}`);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe('withoutTrust', () => {
|
|
307
|
+
function trusted(): INotebookContent {
|
|
308
|
+
const content = notebook();
|
|
309
|
+
content.cells = content.cells.map(cell => ({
|
|
310
|
+
...cell,
|
|
311
|
+
metadata: { ...cell.metadata, trusted: true, tags: ['keep'] }
|
|
312
|
+
}));
|
|
313
|
+
return content;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
it('removes trusted from every cell and keeps other metadata', () => {
|
|
317
|
+
const cells = withoutTrust(trusted()).cells;
|
|
318
|
+
|
|
319
|
+
expect(cells.every(cell => !('trusted' in cell.metadata))).toBe(true);
|
|
320
|
+
expect(
|
|
321
|
+
cells.every(cell => (cell.metadata.tags as string[])[0] === 'keep')
|
|
322
|
+
).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('does not mutate the input', () => {
|
|
326
|
+
const content = trusted();
|
|
327
|
+
|
|
328
|
+
withoutTrust(content);
|
|
329
|
+
|
|
330
|
+
expect(content.cells[0].metadata.trusted).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('is applied when opening a record, so shared outputs render sanitised', () => {
|
|
334
|
+
const content = notebookFromRecord(
|
|
335
|
+
{
|
|
336
|
+
id: ID,
|
|
337
|
+
name: 'n',
|
|
338
|
+
description: null,
|
|
339
|
+
modified: '2026-09-12T10:00:00',
|
|
340
|
+
creator: null,
|
|
341
|
+
spec: trusted() as never
|
|
342
|
+
},
|
|
343
|
+
SPECS
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
expect(content.cells.some(cell => 'trusted' in cell.metadata)).toBe(false);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('is applied when saving, so other clients never receive a trust claim', () => {
|
|
350
|
+
const result = buildSpecBody(trusted(), 'n.ipynb', SPECS);
|
|
351
|
+
|
|
352
|
+
expect(
|
|
353
|
+
result.ok &&
|
|
354
|
+
result.body.spec.cells.some(cell => 'trusted' in cell.metadata)
|
|
355
|
+
).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe('parseShareEmails', () => {
|
|
360
|
+
it('splits on commas, semicolons, spaces and new lines', () => {
|
|
361
|
+
const raw =
|
|
362
|
+
'a@example.com, b@example.com;c@example.com d@example.com\ne@example.com';
|
|
363
|
+
expect(parseShareEmails(raw)).toEqual({
|
|
364
|
+
emails: [
|
|
365
|
+
'a@example.com',
|
|
366
|
+
'b@example.com',
|
|
367
|
+
'c@example.com',
|
|
368
|
+
'd@example.com',
|
|
369
|
+
'e@example.com'
|
|
370
|
+
],
|
|
371
|
+
invalid: []
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('lower-cases addresses, because the store matches the claim case-sensitively', () => {
|
|
376
|
+
expect(parseShareEmails('Alice@Example.COM').emails).toEqual([
|
|
377
|
+
'alice@example.com'
|
|
378
|
+
]);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('removes duplicates, keeping the order first written', () => {
|
|
382
|
+
expect(
|
|
383
|
+
parseShareEmails('b@example.com a@example.com B@example.com').emails
|
|
384
|
+
).toEqual(['b@example.com', 'a@example.com']);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('separates the addresses it cannot grant, reported as they were typed', () => {
|
|
388
|
+
const parsed = parseShareEmails(
|
|
389
|
+
'good@example.com, nope, *@example.com, No@Good'
|
|
390
|
+
);
|
|
391
|
+
expect(parsed.emails).toEqual(['good@example.com']);
|
|
392
|
+
expect(parsed.invalid).toEqual(['nope', '*@example.com', 'No@Good']);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('ignores surrounding and repeated separators', () => {
|
|
396
|
+
expect(parseShareEmails(' ,, a@example.com ,, ')).toEqual({
|
|
397
|
+
emails: ['a@example.com'],
|
|
398
|
+
invalid: []
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('returns nothing for empty input', () => {
|
|
403
|
+
expect(parseShareEmails(' ')).toEqual({ emails: [], invalid: [] });
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('reportForShareFailures', () => {
|
|
408
|
+
it('keeps the original error for a failed public share, which names nobody', () => {
|
|
409
|
+
// The public grant's entity is '', so a message built from entity names would read
|
|
410
|
+
// "Could not share with ." and lose the real reason.
|
|
411
|
+
expect(reportForShareFailures(1, [''], false)).toEqual({
|
|
412
|
+
kind: 'original'
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('keeps the original error when the session needs signing in again', () => {
|
|
417
|
+
// Anything synthetic is not a SpecStoreError, so the reporter would not offer
|
|
418
|
+
// sign-in.
|
|
419
|
+
expect(
|
|
420
|
+
reportForShareFailures(3, ['a@example.com', 'b@example.com'], true)
|
|
421
|
+
).toEqual({ kind: 'original' });
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('keeps the original error when every grant failed', () => {
|
|
425
|
+
expect(
|
|
426
|
+
reportForShareFailures(2, ['a@example.com', 'b@example.com'], false)
|
|
427
|
+
).toEqual({ kind: 'original' });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('never builds a message that names nobody, even with grants left over', () => {
|
|
431
|
+
// Isolates the unnamed-entity guard: here the all-failed condition does not apply,
|
|
432
|
+
// so only that guard stops the caller describing a failure as "with ".
|
|
433
|
+
expect(reportForShareFailures(2, [''], false)).toEqual({
|
|
434
|
+
kind: 'original'
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('names the failures when some of them worked', () => {
|
|
439
|
+
expect(reportForShareFailures(3, ['b@example.com'], false)).toEqual({
|
|
440
|
+
kind: 'partial',
|
|
441
|
+
named: ['b@example.com']
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('ignores an unnamed grant when deciding who to name', () => {
|
|
446
|
+
expect(reportForShareFailures(3, ['', 'b@example.com'], false)).toEqual({
|
|
447
|
+
kind: 'partial',
|
|
448
|
+
named: ['b@example.com']
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
});
|