jupyterlab_ai_code_assistants_extension 1.0.39 → 1.0.41
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/core/panel.d.ts +2 -2
- package/lib/core/panel.js +36 -5
- package/lib/core/request.d.ts +1 -0
- package/lib/core/request.js +19 -1
- package/lib/index.js +11 -0
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +127 -0
- package/src/__tests__/panel.spec.ts +130 -1
- package/src/__tests__/request.spec.ts +47 -0
- package/src/core/panel.ts +40 -5
- package/src/core/request.ts +21 -1
- package/src/index.ts +11 -0
package/lib/core/panel.d.ts
CHANGED
|
@@ -89,8 +89,8 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
89
89
|
* JupyterLab's move-to-trash setting. Settable because a panel docked before
|
|
90
90
|
* the server has answered its first status probe is built without either
|
|
91
91
|
* (DEF-132); the action paths read them lazily, so a late value applies on
|
|
92
|
-
* the next click,
|
|
93
|
-
*
|
|
92
|
+
* the next click, and a root arriving after rows were drawn redraws them
|
|
93
|
+
* once (DEF-136).
|
|
94
94
|
*
|
|
95
95
|
* Trailing slashes go, but never the root itself: serving JupyterLab at
|
|
96
96
|
* `/` used to normalise to the empty string, which reads as "no root" -
|
package/lib/core/panel.js
CHANGED
|
@@ -508,6 +508,18 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
508
508
|
_showError(err) {
|
|
509
509
|
const message = err instanceof Error ? err.message : String(err);
|
|
510
510
|
this._logError(message);
|
|
511
|
+
// DEF-138: the server ANSWERED, and its answer is that this assistant's
|
|
512
|
+
// binary is not on the PATH the Jupyter server runs with. Blaming the
|
|
513
|
+
// server names the wrong cause - the panel is docked only because no
|
|
514
|
+
// roster has arrived yet, and the roster is what removes it (DEF-132).
|
|
515
|
+
// The error code is matched as well as the status, because a 503 raised
|
|
516
|
+
// by a proxy or a stopped single-user server is NOT this: its body never
|
|
517
|
+
// parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
|
|
518
|
+
if (isResponseStatus(err, 503) && message === 'cli_not_found') {
|
|
519
|
+
this._setInlineError(`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
|
|
520
|
+
`server's PATH.`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
511
523
|
const copy = isRequestTimeout(err)
|
|
512
524
|
? 'The server is not answering - retrying.'
|
|
513
525
|
: 'Could not reach the server - retrying.';
|
|
@@ -714,8 +726,8 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
714
726
|
* JupyterLab's move-to-trash setting. Settable because a panel docked before
|
|
715
727
|
* the server has answered its first status probe is built without either
|
|
716
728
|
* (DEF-132); the action paths read them lazily, so a late value applies on
|
|
717
|
-
* the next click,
|
|
718
|
-
*
|
|
729
|
+
* the next click, and a root arriving after rows were drawn redraws them
|
|
730
|
+
* once (DEF-136).
|
|
719
731
|
*
|
|
720
732
|
* Trailing slashes go, but never the root itself: serving JupyterLab at
|
|
721
733
|
* `/` used to normalise to the empty string, which reads as "no root" -
|
|
@@ -724,9 +736,20 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
724
736
|
setRoot(rootDir, deleteToTrash) {
|
|
725
737
|
var _a;
|
|
726
738
|
const root = (rootDir || '').replace(/\/+$/, '');
|
|
727
|
-
|
|
739
|
+
const next = root || (rootDir ? '/' : '');
|
|
740
|
+
const changed = next !== this._rootDir;
|
|
741
|
+
this._rootDir = next;
|
|
728
742
|
this._deleteToTrash = deleteToTrash;
|
|
729
743
|
(_a = this._renameNewButton) === null || _a === void 0 ? void 0 : _a.call(this);
|
|
744
|
+
// Guarded on the VALUE, not on the call: `reconcile` re-sends the same
|
|
745
|
+
// root every status tick, so a changed-guard redraws exactly once, on the
|
|
746
|
+
// '' -> root transition. `_sessions` stands in for "the shell is built and
|
|
747
|
+
// rows are on screen" - it is null until the first `_fetch` answers, which
|
|
748
|
+
// is what keeps the constructor's own `setRoot` from reaching `_render`
|
|
749
|
+
// before `_buildShell` has created `_bodyEl`.
|
|
750
|
+
if (changed && this._sessions !== null) {
|
|
751
|
+
this._render();
|
|
752
|
+
}
|
|
730
753
|
}
|
|
731
754
|
/** Absolute path of the file browser's current folder; the server root when
|
|
732
755
|
* no file browser is available. */
|
|
@@ -1770,7 +1793,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1770
1793
|
// absolute filesystem path, so it goes through _pathUnderRoot.
|
|
1771
1794
|
const rel = this._pathUnderRoot(s.project_path);
|
|
1772
1795
|
if (rel === null) {
|
|
1773
|
-
|
|
1796
|
+
// A root the server has not sent yet is not a folder outside it
|
|
1797
|
+
// (DEF-134).
|
|
1798
|
+
Notification.warning(this._rootDir
|
|
1799
|
+
? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
|
|
1800
|
+
: 'Waiting for the server root - cannot open a terminal yet.', { autoClose: 4000 });
|
|
1774
1801
|
return;
|
|
1775
1802
|
}
|
|
1776
1803
|
this._app.commands
|
|
@@ -1788,7 +1815,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1788
1815
|
}
|
|
1789
1816
|
const rel = this._pathUnderRoot(s.project_path);
|
|
1790
1817
|
if (rel === null) {
|
|
1791
|
-
|
|
1818
|
+
// A root the server has not sent yet is not a folder outside it
|
|
1819
|
+
// (DEF-134).
|
|
1820
|
+
Notification.warning(this._rootDir
|
|
1821
|
+
? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
|
|
1822
|
+
: 'Waiting for the server root - the file browser cannot show it yet.', { autoClose: 4000 });
|
|
1792
1823
|
return;
|
|
1793
1824
|
}
|
|
1794
1825
|
this._app.commands
|
package/lib/core/request.d.ts
CHANGED
|
@@ -56,6 +56,7 @@ export declare function isRequestTimeout(err: unknown): boolean;
|
|
|
56
56
|
* @returns The response body interpreted as JSON
|
|
57
57
|
* @throws RequestTimeoutError when the deadline passes before the server
|
|
58
58
|
* answers
|
|
59
|
+
* @throws Error when a 200 carries a body this helper cannot parse
|
|
59
60
|
*/
|
|
60
61
|
export declare function requestAPI<T>(endPoint: string, serverSettings: ServerConnection.ISettings, init?: RequestInit): Promise<T>;
|
|
61
62
|
/** Build a provider-scoped path: `providers/<id>/<path>`. The id is encoded so
|
package/lib/core/request.js
CHANGED
|
@@ -63,6 +63,7 @@ export function isRequestTimeout(err) {
|
|
|
63
63
|
* @returns The response body interpreted as JSON
|
|
64
64
|
* @throws RequestTimeoutError when the deadline passes before the server
|
|
65
65
|
* answers
|
|
66
|
+
* @throws Error when a 200 carries a body this helper cannot parse
|
|
66
67
|
*/
|
|
67
68
|
export async function requestAPI(endPoint, serverSettings, init = {}) {
|
|
68
69
|
var _a, _b;
|
|
@@ -111,7 +112,24 @@ export async function requestAPI(endPoint, serverSettings, init = {}) {
|
|
|
111
112
|
data = JSON.parse(data);
|
|
112
113
|
}
|
|
113
114
|
catch (_c) {
|
|
114
|
-
|
|
115
|
+
if (response.ok) {
|
|
116
|
+
// DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
|
|
117
|
+
// a 200 this helper cannot parse did not come from this server - it is
|
|
118
|
+
// an SSO or proxy interstitial served in its place. Returning the raw
|
|
119
|
+
// string let every caller read `undefined` off it: `probeStatus` wrote
|
|
120
|
+
// a roster with no `providers` and `reconcile` undocked all four
|
|
121
|
+
// panels, `_fetch` rendered an empty session list, and
|
|
122
|
+
// `ColourStore.load` replaced the cache with an empty map. Refusing it
|
|
123
|
+
// makes the probe FAIL, and a failed probe writes nothing (DEF-132),
|
|
124
|
+
// so the last known roster stands.
|
|
125
|
+
throw new Error(`Response to ${endPoint} was not JSON (HTTP ${response.status})`);
|
|
126
|
+
}
|
|
127
|
+
// Not ok: the `ResponseError` below carries this body as its message, so
|
|
128
|
+
// this side is no longer a silent path and a proxy's error page here is
|
|
129
|
+
// noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
|
|
130
|
+
// only on the refusal side; the 200 side is now louder than the `warn`
|
|
131
|
+
// it asked for.
|
|
132
|
+
console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
|
|
115
133
|
}
|
|
116
134
|
}
|
|
117
135
|
if (!response.ok) {
|
package/lib/index.js
CHANGED
|
@@ -226,6 +226,17 @@ const plugin = {
|
|
|
226
226
|
// gets a notification, so the COMMON failure was the quiet one.
|
|
227
227
|
Notification.warning(`${label} is enabled but \`${binary}\` was not found on the Jupyter server's PATH, so its panel is not shown.`, { autoClose: 8000 });
|
|
228
228
|
}
|
|
229
|
+
// The latch is per ABSENCE, not per page load. Once the server reports
|
|
230
|
+
// the binary present again, a later disappearance gets its own warning:
|
|
231
|
+
// without this, one transient absence - a CLI upgrade unlinking the
|
|
232
|
+
// binary for a moment, or an obsolete roster from an overlapping probe -
|
|
233
|
+
// permanently suppresses the real one (DEF-125). It cannot make the
|
|
234
|
+
// warning repeat while the state is unchanged, which is what the
|
|
235
|
+
// once-per-id latch above exists to prevent: the two conditions are
|
|
236
|
+
// mutually exclusive.
|
|
237
|
+
if (probe !== null && probe.available !== false) {
|
|
238
|
+
warnedUnavailable.delete(id);
|
|
239
|
+
}
|
|
229
240
|
if (enabled && available) {
|
|
230
241
|
if (live.has(id)) {
|
|
231
242
|
const panel = live.get(id).panel;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_ai_code_assistants_extension",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.41",
|
|
4
4
|
"description": "Jupyterlab extension to replace individual jupyterlab_*_code_extension and integrate into one, that offers joint capability, joint settings page and individual right toolbar panels per code assistant, with the ability in settings to decide which code assistant support is on (default - all are enabled)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jupyter",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's own activation path - the only tier that reaches it.
|
|
3
|
+
*
|
|
4
|
+
* `src/index.ts` carried no Jest coverage at all (0 of 151 lines), and the
|
|
5
|
+
* Galata DEF-132 spec stops at the panels docking, before the first probe ever
|
|
6
|
+
* answers. The branch below is what makes a panel docked without a roster
|
|
7
|
+
* usable once one arrives: `reconcile` pushes the late `root_dir` and trash
|
|
8
|
+
* flag into the already-live panel (DEF-GUARD-139).
|
|
9
|
+
*
|
|
10
|
+
* Nothing here waits on a timer. The activation probe fails, the roster is
|
|
11
|
+
* delivered by dispatching `online`, and the promise chain is drained through
|
|
12
|
+
* the microtask queue.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// `@jupyterlab/apputils`' barrel reaches two ESM-only packages the repo jest
|
|
16
|
+
// config's `esModules` allowlist does not carry - the same two `panel.spec.ts`
|
|
17
|
+
// stubs, for the same reason.
|
|
18
|
+
jest.mock('@jupyter/react-components', () => ({}));
|
|
19
|
+
jest.mock('@jupyter/web-components', () => ({
|
|
20
|
+
addJupyterLabThemeChangeListener: () => undefined,
|
|
21
|
+
applyJupyterTheme: () => undefined,
|
|
22
|
+
jpButton: () => undefined,
|
|
23
|
+
jpToolbar: () => undefined,
|
|
24
|
+
provideJupyterDesignSystem: () => ({ register: () => undefined })
|
|
25
|
+
}));
|
|
26
|
+
// Both tokens are named in the plugin's `optional:` list, so both packages
|
|
27
|
+
// load for real; `@jupyterlab/terminal` pulls the ESM-only `color` package and
|
|
28
|
+
// the tab extension ships untransformed ESM. Neither token is used here.
|
|
29
|
+
jest.mock('@jupyterlab/terminal', () => ({ ITerminalTracker: {} }));
|
|
30
|
+
jest.mock('jupyterlab_colourful_tab_extension', () => ({ IColourfulTabs: {} }));
|
|
31
|
+
// No route in this spec reaches the server.
|
|
32
|
+
jest.mock('../core/request', () => ({
|
|
33
|
+
requestAPI: jest.fn(),
|
|
34
|
+
requestProvider: jest.fn(() => Promise.resolve({})),
|
|
35
|
+
isRequestTimeout: () => false,
|
|
36
|
+
isResponseStatus: () => false,
|
|
37
|
+
withQuery: (path: string) => path
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
41
|
+
import { Signal } from '@lumino/signaling';
|
|
42
|
+
|
|
43
|
+
import { AssistantSessionsPanel } from '../core/panel';
|
|
44
|
+
import { requestAPI } from '../core/request';
|
|
45
|
+
import plugin from '../index';
|
|
46
|
+
|
|
47
|
+
const request = requestAPI as jest.Mock;
|
|
48
|
+
|
|
49
|
+
/** Drain the microtask queue - `probeStatus().then(reconcile)` and nothing
|
|
50
|
+
* longer-lived, so no timer is involved. */
|
|
51
|
+
async function flush(turns = 8): Promise<void> {
|
|
52
|
+
for (let i = 0; i < turns; i += 1) {
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () => {
|
|
58
|
+
it('a panel docked before the first roster gains the root and the trash flag', async () => {
|
|
59
|
+
let roster: unknown = null;
|
|
60
|
+
request.mockImplementation((path: string) =>
|
|
61
|
+
path === 'status' && roster
|
|
62
|
+
? Promise.resolve(roster)
|
|
63
|
+
: Promise.reject(new Error('server down'))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const docked: AssistantSessionsPanel[] = [];
|
|
67
|
+
const labShell = {
|
|
68
|
+
add: (w: AssistantSessionsPanel) => {
|
|
69
|
+
docked.push(w);
|
|
70
|
+
}
|
|
71
|
+
} as any;
|
|
72
|
+
const app = {
|
|
73
|
+
serviceManager: { serverSettings: {} },
|
|
74
|
+
commands: new CommandRegistry(),
|
|
75
|
+
hasPlugin: () => false
|
|
76
|
+
} as any;
|
|
77
|
+
const settings = {
|
|
78
|
+
composite: {},
|
|
79
|
+
get: () => ({ composite: undefined, user: undefined }),
|
|
80
|
+
set: async () => undefined,
|
|
81
|
+
changed: new Signal<unknown, void>({})
|
|
82
|
+
};
|
|
83
|
+
const settingRegistry = { load: async () => settings } as any;
|
|
84
|
+
const fileBrowser = { model: { path: 'data/raw' } } as any;
|
|
85
|
+
|
|
86
|
+
await (plugin.activate as any)(
|
|
87
|
+
app,
|
|
88
|
+
labShell,
|
|
89
|
+
null,
|
|
90
|
+
settingRegistry,
|
|
91
|
+
null,
|
|
92
|
+
fileBrowser,
|
|
93
|
+
null
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// The activation probe failed, so every enabled assistant docked with no
|
|
97
|
+
// root and, with the roster unknown, with trash treated as unavailable.
|
|
98
|
+
const panel = docked.find(w => w.node.dataset.provider === 'claude')!;
|
|
99
|
+
expect(panel).toBeDefined();
|
|
100
|
+
expect((panel as any)._rootDir).toBe('');
|
|
101
|
+
expect((panel as any)._deleteToTrash).toBe(false);
|
|
102
|
+
|
|
103
|
+
// A later probe answers. The panel is already live, so `start` is not the
|
|
104
|
+
// path that carries the root to it - `reconcile` is.
|
|
105
|
+
roster = {
|
|
106
|
+
providers: [
|
|
107
|
+
{ id: 'claude', available: true },
|
|
108
|
+
{ id: 'codex', available: true },
|
|
109
|
+
{ id: 'kimi', available: true },
|
|
110
|
+
{ id: 'gemini', available: true }
|
|
111
|
+
],
|
|
112
|
+
root_dir: '/srv/lab',
|
|
113
|
+
delete_to_trash: true
|
|
114
|
+
};
|
|
115
|
+
window.dispatchEvent(new Event('online'));
|
|
116
|
+
await flush();
|
|
117
|
+
|
|
118
|
+
// How this fails: drop the `panel.setRoot(...)` call in `reconcile` and
|
|
119
|
+
// the root stays empty forever - the + button never arms and every path
|
|
120
|
+
// resolution answers "outside the JupyterLab root".
|
|
121
|
+
expect((panel as any)._rootDir).toBe('/srv/lab');
|
|
122
|
+
expect((panel as any)._deleteToTrash).toBe(true);
|
|
123
|
+
expect((panel as any)._currentFolder()).toBe('/srv/lab/data/raw');
|
|
124
|
+
|
|
125
|
+
docked.forEach(w => w.dispose());
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -37,10 +37,15 @@ jest.mock('@jupyter/web-components', () => ({
|
|
|
37
37
|
// before each act rather than asserted from construction.
|
|
38
38
|
jest.mock('../core/request', () => ({
|
|
39
39
|
requestProvider: jest.fn(() => Promise.resolve({})),
|
|
40
|
-
|
|
40
|
+
// Shape-based rather than `instanceof`, so a test can hand the panel the
|
|
41
|
+
// refusal a route answers with without building a real `Response`.
|
|
42
|
+
isRequestTimeout: (err: any) => err?.name === 'RequestTimeoutError',
|
|
43
|
+
isResponseStatus: (err: any, status: number) =>
|
|
44
|
+
err?.response?.status === status,
|
|
41
45
|
withQuery: (path: string) => path
|
|
42
46
|
}));
|
|
43
47
|
|
|
48
|
+
import { Notification } from '@jupyterlab/apputils';
|
|
44
49
|
import { TAB_COLOUR_IDS, fnv1aColour } from '../core/colour';
|
|
45
50
|
import { addIcon, branchIcon, cleanupIcon, shieldIcon } from '../core/icons';
|
|
46
51
|
import { AssistantSessionsPanel, commandId } from '../core/panel';
|
|
@@ -661,4 +666,128 @@ describe('DEF-132 - a panel docked before the first roster has no root', () => {
|
|
|
661
666
|
expect((p as any)._currentFolder()).toBe('/data/raw');
|
|
662
667
|
p.dispose();
|
|
663
668
|
});
|
|
669
|
+
|
|
670
|
+
it('the path actions name the missing root, not a folder outside it', async () => {
|
|
671
|
+
const p = rootless();
|
|
672
|
+
await flush();
|
|
673
|
+
const warn = jest.spyOn(Notification, 'warning').mockReturnValue('' as any);
|
|
674
|
+
|
|
675
|
+
// The folder is INSIDE the root the server has not sent yet, so blaming
|
|
676
|
+
// the root is the one diagnosis that cannot be acted on (DEF-134).
|
|
677
|
+
(p as any)._activeSession = session({ project_path: '/srv/lab/proj' });
|
|
678
|
+
await (p as any)._commands.execute(commandId('testbed', 'open-terminal'));
|
|
679
|
+
await (p as any)._commands.execute(
|
|
680
|
+
commandId('testbed', 'show-in-filebrowser')
|
|
681
|
+
);
|
|
682
|
+
expect(warn.mock.calls.map(c => String(c[0]))).toEqual([
|
|
683
|
+
'Waiting for the server root - cannot open a terminal yet.',
|
|
684
|
+
'Waiting for the server root - the file browser cannot show it yet.'
|
|
685
|
+
]);
|
|
686
|
+
|
|
687
|
+
// And the outside case is still named, rather than deleted with it.
|
|
688
|
+
warn.mockClear();
|
|
689
|
+
p.setRoot('/srv/lab', true);
|
|
690
|
+
(p as any)._activeSession = session({ project_path: '/elsewhere/proj' });
|
|
691
|
+
await (p as any)._commands.execute(commandId('testbed', 'open-terminal'));
|
|
692
|
+
expect(String(warn.mock.calls[0][0])).toContain(
|
|
693
|
+
'outside the JupyterLab root'
|
|
694
|
+
);
|
|
695
|
+
p.dispose();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
// DEF-136 - the root can land after the rows are already on screen.
|
|
699
|
+
|
|
700
|
+
it('a root arriving after the rows redraws their tooltips', async () => {
|
|
701
|
+
const p = rootless();
|
|
702
|
+
await flush();
|
|
703
|
+
render(p, [session()]);
|
|
704
|
+
|
|
705
|
+
// Pre-roster the absolute path is the honest fallback.
|
|
706
|
+
const row = p.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-row')!;
|
|
707
|
+
expect(row.title).toContain('Path: /home/user/proj');
|
|
708
|
+
|
|
709
|
+
p.setRoot('/home/user', true);
|
|
710
|
+
expect(
|
|
711
|
+
p.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-row')!.title
|
|
712
|
+
).toContain('Path: proj');
|
|
713
|
+
p.dispose();
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it('a root arriving after the rows redraws their labels in path mode', async () => {
|
|
717
|
+
const p = rootless();
|
|
718
|
+
await flush();
|
|
719
|
+
p.setPresentationMode('path');
|
|
720
|
+
render(p, [session()]);
|
|
721
|
+
expect(
|
|
722
|
+
p.node.querySelector('.jp-AiAssistantsPanel-nameText')!.textContent
|
|
723
|
+
).toBe('/home/user/proj');
|
|
724
|
+
|
|
725
|
+
p.setRoot('/home/user', true);
|
|
726
|
+
expect(
|
|
727
|
+
p.node.querySelector('.jp-AiAssistantsPanel-nameText')!.textContent
|
|
728
|
+
).toBe('proj');
|
|
729
|
+
p.dispose();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it('an unchanged root does not redraw - the reconcile tick repeats it', async () => {
|
|
733
|
+
const p = rootless();
|
|
734
|
+
await flush();
|
|
735
|
+
render(p, [session()]);
|
|
736
|
+
p.setRoot('/home/user', true);
|
|
737
|
+
|
|
738
|
+
const spy = jest.spyOn(p as any, '_render');
|
|
739
|
+
p.setRoot('/home/user', true);
|
|
740
|
+
p.setRoot('/home/user/', true);
|
|
741
|
+
expect(spy).not.toHaveBeenCalled();
|
|
742
|
+
p.dispose();
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// --------------------------------------------------------------- DEF-138
|
|
747
|
+
|
|
748
|
+
describe('DEF-138 - a refused listing names the missing binary', () => {
|
|
749
|
+
const banner = (): string =>
|
|
750
|
+
panel.node.querySelector<HTMLElement>('.jp-AiAssistantsPanel-error')!
|
|
751
|
+
.textContent ?? '';
|
|
752
|
+
|
|
753
|
+
beforeEach(() => {
|
|
754
|
+
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
/** What `requestAPI` throws for the server's `{"error": "cli_not_found"}` -
|
|
758
|
+
* the only 503 a listing can be refused with. */
|
|
759
|
+
const cliNotFound = (): Error =>
|
|
760
|
+
Object.assign(new Error('cli_not_found'), {
|
|
761
|
+
name: 'ResponseError',
|
|
762
|
+
response: { status: 503 }
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it('names the binary instead of blaming the server', () => {
|
|
766
|
+
(panel as any)._showError(cliNotFound());
|
|
767
|
+
expect(banner()).toContain('`testbed` was not found');
|
|
768
|
+
expect(banner()).not.toContain('Could not reach the server');
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("leaves a 503 that is not this extension's own alone", () => {
|
|
772
|
+
// What a hub proxy or a stopped single-user server answers: a 503 whose
|
|
773
|
+
// body is HTML, so `requestAPI` hands the raw text through as the message.
|
|
774
|
+
(panel as any)._showError(
|
|
775
|
+
Object.assign(new Error('<html>503 Service Unavailable</html>'), {
|
|
776
|
+
name: 'ResponseError',
|
|
777
|
+
response: { status: 503 }
|
|
778
|
+
})
|
|
779
|
+
);
|
|
780
|
+
expect(banner()).toContain('Could not reach the server');
|
|
781
|
+
expect(banner()).not.toContain('was not found');
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('leaves the two unanswered-server verdicts alone', () => {
|
|
785
|
+
(panel as any)._showError(
|
|
786
|
+
Object.assign(new Error('timed out'), { name: 'RequestTimeoutError' })
|
|
787
|
+
);
|
|
788
|
+
expect(banner()).toContain('The server is not answering');
|
|
789
|
+
|
|
790
|
+
(panel as any)._showError(new TypeError('Failed to fetch'));
|
|
791
|
+
expect(banner()).toContain('Could not reach the server');
|
|
792
|
+
});
|
|
664
793
|
});
|
|
@@ -195,3 +195,50 @@ describe('requestAPI with a caller-supplied signal', () => {
|
|
|
195
195
|
).resolves.toEqual({ ok: true });
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
|
+
|
|
199
|
+
describe('requestAPI on a body it cannot parse', () => {
|
|
200
|
+
it('refuses a 200 that is not JSON instead of returning the raw string', async () => {
|
|
201
|
+
// DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so a
|
|
202
|
+
// 200 this helper cannot parse came from something in front of the server.
|
|
203
|
+
// Returned as data, `status.providers` read `undefined` and every panel
|
|
204
|
+
// undocked; thrown, the probe FAILS and the last roster stands (DEF-132).
|
|
205
|
+
makeRequest.mockResolvedValue({
|
|
206
|
+
ok: true,
|
|
207
|
+
status: 200,
|
|
208
|
+
text: async () =>
|
|
209
|
+
'<!doctype html><html><head><title>Sign in</title></head><body>SSO</body></html>'
|
|
210
|
+
} as unknown as Response);
|
|
211
|
+
|
|
212
|
+
await expect(requestAPI('status', SETTINGS)).rejects.toThrow(
|
|
213
|
+
/was not JSON/
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('still resolves an empty 200 body to the empty string', async () => {
|
|
218
|
+
// The `data.length > 0` guard is untouched: a 204-shaped answer is not a
|
|
219
|
+
// parse failure and must not become one.
|
|
220
|
+
makeRequest.mockResolvedValue({
|
|
221
|
+
ok: true,
|
|
222
|
+
status: 200,
|
|
223
|
+
text: async () => ''
|
|
224
|
+
} as unknown as Response);
|
|
225
|
+
|
|
226
|
+
await expect(requestAPI('status', SETTINGS)).resolves.toBe('');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('keeps a refused non-JSON body a ResponseError', async () => {
|
|
230
|
+
// The refusal side is the DEF-116 case - a proxy error page - and it must
|
|
231
|
+
// keep carrying the body as the `ResponseError` message rather than
|
|
232
|
+
// becoming the new error.
|
|
233
|
+
makeRequest.mockResolvedValue({
|
|
234
|
+
ok: false,
|
|
235
|
+
status: 502,
|
|
236
|
+
text: async () => '<html>bad gateway</html>'
|
|
237
|
+
} as unknown as Response);
|
|
238
|
+
|
|
239
|
+
const error = await capture(requestAPI('status', SETTINGS));
|
|
240
|
+
|
|
241
|
+
expect(error).toBeInstanceOf(ServerConnection.ResponseError);
|
|
242
|
+
expect((error as Error).message).toContain('bad gateway');
|
|
243
|
+
});
|
|
244
|
+
});
|
package/src/core/panel.ts
CHANGED
|
@@ -590,6 +590,22 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
590
590
|
private _showError(err: unknown): void {
|
|
591
591
|
const message = err instanceof Error ? err.message : String(err);
|
|
592
592
|
this._logError(message);
|
|
593
|
+
|
|
594
|
+
// DEF-138: the server ANSWERED, and its answer is that this assistant's
|
|
595
|
+
// binary is not on the PATH the Jupyter server runs with. Blaming the
|
|
596
|
+
// server names the wrong cause - the panel is docked only because no
|
|
597
|
+
// roster has arrived yet, and the roster is what removes it (DEF-132).
|
|
598
|
+
// The error code is matched as well as the status, because a 503 raised
|
|
599
|
+
// by a proxy or a stopped single-user server is NOT this: its body never
|
|
600
|
+
// parses to `{"error": "cli_not_found"}`, so it keeps the copy below.
|
|
601
|
+
if (isResponseStatus(err, 503) && message === 'cli_not_found') {
|
|
602
|
+
this._setInlineError(
|
|
603
|
+
`\`${this._descriptor.cliBinary}\` was not found on the Jupyter ` +
|
|
604
|
+
`server's PATH.`
|
|
605
|
+
);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
593
609
|
const copy = isRequestTimeout(err)
|
|
594
610
|
? 'The server is not answering - retrying.'
|
|
595
611
|
: 'Could not reach the server - retrying.';
|
|
@@ -846,8 +862,8 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
846
862
|
* JupyterLab's move-to-trash setting. Settable because a panel docked before
|
|
847
863
|
* the server has answered its first status probe is built without either
|
|
848
864
|
* (DEF-132); the action paths read them lazily, so a late value applies on
|
|
849
|
-
* the next click,
|
|
850
|
-
*
|
|
865
|
+
* the next click, and a root arriving after rows were drawn redraws them
|
|
866
|
+
* once (DEF-136).
|
|
851
867
|
*
|
|
852
868
|
* Trailing slashes go, but never the root itself: serving JupyterLab at
|
|
853
869
|
* `/` used to normalise to the empty string, which reads as "no root" -
|
|
@@ -855,9 +871,20 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
855
871
|
* Browser answered "outside the JupyterLab root" for every row inside it. */
|
|
856
872
|
setRoot(rootDir: string, deleteToTrash: boolean): void {
|
|
857
873
|
const root = (rootDir || '').replace(/\/+$/, '');
|
|
858
|
-
|
|
874
|
+
const next = root || (rootDir ? '/' : '');
|
|
875
|
+
const changed = next !== this._rootDir;
|
|
876
|
+
this._rootDir = next;
|
|
859
877
|
this._deleteToTrash = deleteToTrash;
|
|
860
878
|
this._renameNewButton?.();
|
|
879
|
+
// Guarded on the VALUE, not on the call: `reconcile` re-sends the same
|
|
880
|
+
// root every status tick, so a changed-guard redraws exactly once, on the
|
|
881
|
+
// '' -> root transition. `_sessions` stands in for "the shell is built and
|
|
882
|
+
// rows are on screen" - it is null until the first `_fetch` answers, which
|
|
883
|
+
// is what keeps the constructor's own `setRoot` from reaching `_render`
|
|
884
|
+
// before `_buildShell` has created `_bodyEl`.
|
|
885
|
+
if (changed && this._sessions !== null) {
|
|
886
|
+
this._render();
|
|
887
|
+
}
|
|
861
888
|
}
|
|
862
889
|
|
|
863
890
|
/** Absolute path of the file browser's current folder; the server root when
|
|
@@ -2078,8 +2105,12 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
2078
2105
|
// absolute filesystem path, so it goes through _pathUnderRoot.
|
|
2079
2106
|
const rel = this._pathUnderRoot(s.project_path);
|
|
2080
2107
|
if (rel === null) {
|
|
2108
|
+
// A root the server has not sent yet is not a folder outside it
|
|
2109
|
+
// (DEF-134).
|
|
2081
2110
|
Notification.warning(
|
|
2082
|
-
|
|
2111
|
+
this._rootDir
|
|
2112
|
+
? 'Folder is outside the JupyterLab root - cannot open a terminal there.'
|
|
2113
|
+
: 'Waiting for the server root - cannot open a terminal yet.',
|
|
2083
2114
|
{ autoClose: 4000 }
|
|
2084
2115
|
);
|
|
2085
2116
|
return;
|
|
@@ -2105,8 +2136,12 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
2105
2136
|
}
|
|
2106
2137
|
const rel = this._pathUnderRoot(s.project_path);
|
|
2107
2138
|
if (rel === null) {
|
|
2139
|
+
// A root the server has not sent yet is not a folder outside it
|
|
2140
|
+
// (DEF-134).
|
|
2108
2141
|
Notification.warning(
|
|
2109
|
-
|
|
2142
|
+
this._rootDir
|
|
2143
|
+
? 'Folder is outside the JupyterLab root - the file browser cannot show it.'
|
|
2144
|
+
: 'Waiting for the server root - the file browser cannot show it yet.',
|
|
2110
2145
|
{ autoClose: 4000 }
|
|
2111
2146
|
);
|
|
2112
2147
|
return;
|
package/src/core/request.ts
CHANGED
|
@@ -71,6 +71,7 @@ export function isRequestTimeout(err: unknown): boolean {
|
|
|
71
71
|
* @returns The response body interpreted as JSON
|
|
72
72
|
* @throws RequestTimeoutError when the deadline passes before the server
|
|
73
73
|
* answers
|
|
74
|
+
* @throws Error when a 200 carries a body this helper cannot parse
|
|
74
75
|
*/
|
|
75
76
|
export async function requestAPI<T>(
|
|
76
77
|
endPoint: string,
|
|
@@ -129,7 +130,26 @@ export async function requestAPI<T>(
|
|
|
129
130
|
try {
|
|
130
131
|
data = JSON.parse(data);
|
|
131
132
|
} catch {
|
|
132
|
-
|
|
133
|
+
if (response.ok) {
|
|
134
|
+
// DEF-118: every handler in `core/routes.py` finishes `json.dumps`, so
|
|
135
|
+
// a 200 this helper cannot parse did not come from this server - it is
|
|
136
|
+
// an SSO or proxy interstitial served in its place. Returning the raw
|
|
137
|
+
// string let every caller read `undefined` off it: `probeStatus` wrote
|
|
138
|
+
// a roster with no `providers` and `reconcile` undocked all four
|
|
139
|
+
// panels, `_fetch` rendered an empty session list, and
|
|
140
|
+
// `ColourStore.load` replaced the cache with an empty map. Refusing it
|
|
141
|
+
// makes the probe FAIL, and a failed probe writes nothing (DEF-132),
|
|
142
|
+
// so the last known roster stands.
|
|
143
|
+
throw new Error(
|
|
144
|
+
`Response to ${endPoint} was not JSON (HTTP ${response.status})`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
// Not ok: the `ResponseError` below carries this body as its message, so
|
|
148
|
+
// this side is no longer a silent path and a proxy's error page here is
|
|
149
|
+
// noise (DEF-116). This is the conditional DEF-118 recorded - `debug`
|
|
150
|
+
// only on the refusal side; the 200 side is now louder than the `warn`
|
|
151
|
+
// it asked for.
|
|
152
|
+
console.debug(`${LOG_PREFIX} Not a JSON response body.`, response);
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
155
|
|
package/src/index.ts
CHANGED
|
@@ -296,6 +296,17 @@ const plugin: JupyterFrontEndPlugin<void> = {
|
|
|
296
296
|
{ autoClose: 8000 }
|
|
297
297
|
);
|
|
298
298
|
}
|
|
299
|
+
// The latch is per ABSENCE, not per page load. Once the server reports
|
|
300
|
+
// the binary present again, a later disappearance gets its own warning:
|
|
301
|
+
// without this, one transient absence - a CLI upgrade unlinking the
|
|
302
|
+
// binary for a moment, or an obsolete roster from an overlapping probe -
|
|
303
|
+
// permanently suppresses the real one (DEF-125). It cannot make the
|
|
304
|
+
// warning repeat while the state is unchanged, which is what the
|
|
305
|
+
// once-per-id latch above exists to prevent: the two conditions are
|
|
306
|
+
// mutually exclusive.
|
|
307
|
+
if (probe !== null && probe.available !== false) {
|
|
308
|
+
warnedUnavailable.delete(id);
|
|
309
|
+
}
|
|
299
310
|
if (enabled && available) {
|
|
300
311
|
if (live.has(id)) {
|
|
301
312
|
const panel = live.get(id)!.panel;
|