@memberjunction/server 5.43.0 → 5.44.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/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +3 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1159 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35284 -29068
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +8 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +28 -4
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionManager.ts +3 -0
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17839 -13567
- package/src/index.ts +10 -0
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +36 -4
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- package/src/rest/mediaRange.ts +76 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
3
|
+
|
|
4
|
+
// ── Mocks ──────────────────────────────────────────────────────────────────
|
|
5
|
+
// The helper uses `RunView.FromMetadataProvider(provider).RunView(...)` for conversation lookups and
|
|
6
|
+
// `FileStorageEngine.Instance` for storage-account/driver/upload. We stub both so the test is pure
|
|
7
|
+
// (no DB, no network, no LiveKit/storage SDK).
|
|
8
|
+
|
|
9
|
+
const runViewMock = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
13
|
+
class RunView {
|
|
14
|
+
static FromMetadataProvider() {
|
|
15
|
+
return { RunView: runViewMock };
|
|
16
|
+
}
|
|
17
|
+
RunView = runViewMock;
|
|
18
|
+
}
|
|
19
|
+
return { ...actual, RunView, LogError: vi.fn(), LogStatus: vi.fn() };
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// FileStorageEngine — a configurable fake. Tests reset its behavior in beforeEach.
|
|
23
|
+
const storage = {
|
|
24
|
+
accountsByProvider: new Map<string, Array<{ ID: string }>>(),
|
|
25
|
+
getObject: vi.fn(async () => Buffer.from('mp4-bytes')),
|
|
26
|
+
uploadFile: vi.fn(async () => ({ FileID: 'copied-file', StoragePath: 'meeting-recordings/2026/x.mp4' })),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
vi.mock('@memberjunction/storage', () => ({
|
|
30
|
+
FileStorageEngine: {
|
|
31
|
+
Instance: {
|
|
32
|
+
Config: vi.fn(async () => undefined),
|
|
33
|
+
GetAccountsByProviderID: (id: string) => storage.accountsByProvider.get(id) ?? [],
|
|
34
|
+
GetDriver: vi.fn(async () => ({ GetObject: storage.getObject })),
|
|
35
|
+
UploadFile: (...args: unknown[]) => storage.uploadFile(...args),
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('@memberjunction/core-entities', async (importOriginal) => {
|
|
41
|
+
const actual = await importOriginal<Record<string, unknown>>();
|
|
42
|
+
return { ...actual };
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
import {
|
|
46
|
+
registerMeetingRecordingFile,
|
|
47
|
+
resolveMeetingConversation,
|
|
48
|
+
type MeetingRecordingEgressResult,
|
|
49
|
+
} from '../resolvers/meetingRecordingRegistration';
|
|
50
|
+
|
|
51
|
+
// ── Fakes ──────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
const user = { ID: 'U1', Name: 'Amith', Email: 'amith@x.com' } as unknown as UserInfo;
|
|
54
|
+
|
|
55
|
+
/** A fake BaseEntity for Conversations / Files — records the values set on it. */
|
|
56
|
+
class FakeEntity {
|
|
57
|
+
public fields: Record<string, unknown> = {};
|
|
58
|
+
public ID = 'new-id';
|
|
59
|
+
public saved = false;
|
|
60
|
+
public loadedID: string | null = null;
|
|
61
|
+
constructor(public kind: 'conversation' | 'file', private saveResult = true) {}
|
|
62
|
+
NewRecord() {}
|
|
63
|
+
async Load(id: string) { this.loadedID = id; return true; }
|
|
64
|
+
async Save() { this.saved = this.saveResult; return this.saveResult; }
|
|
65
|
+
get LatestResult() { return { CompleteMessage: 'save failed' }; }
|
|
66
|
+
// Conversation fields
|
|
67
|
+
set UserID(v: string) { this.fields.UserID = v; }
|
|
68
|
+
set Name(v: string) { this.fields.Name = v; }
|
|
69
|
+
set Type(v: string) { this.fields.Type = v; }
|
|
70
|
+
set ExternalID(v: string) { this.fields.ExternalID = v; }
|
|
71
|
+
set ApplicationScope(v: string) { this.fields.ApplicationScope = v; }
|
|
72
|
+
set RecordingFileID(v: string) { this.fields.RecordingFileID = v; }
|
|
73
|
+
get RecordingFileID() { return this.fields.RecordingFileID as string; }
|
|
74
|
+
set EgressID(v: string) { this.fields.EgressID = v; }
|
|
75
|
+
get EgressID() { return this.fields.EgressID as string; }
|
|
76
|
+
// File fields
|
|
77
|
+
set ContentType(v: string) { this.fields.ContentType = v; }
|
|
78
|
+
set ProviderID(v: string) { this.fields.ProviderID = v; }
|
|
79
|
+
set ProviderKey(v: string) { this.fields.ProviderKey = v; }
|
|
80
|
+
set Status(v: string) { this.fields.Status = v; }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Builds a provider whose GetEntityObject hands back fresh fakes and records the created Files row. */
|
|
84
|
+
function makeProvider(opts: { fileSaveOk?: boolean } = {}) {
|
|
85
|
+
const created: { conversation: FakeEntity[]; file: FakeEntity[] } = { conversation: [], file: [] };
|
|
86
|
+
const provider = {
|
|
87
|
+
GetEntityObject: vi.fn(async (entityName: string) => {
|
|
88
|
+
if (entityName === 'MJ: Files') {
|
|
89
|
+
const f = new FakeEntity('file', opts.fileSaveOk ?? true);
|
|
90
|
+
f.ID = 'file-123';
|
|
91
|
+
created.file.push(f);
|
|
92
|
+
return f;
|
|
93
|
+
}
|
|
94
|
+
const c = new FakeEntity('conversation');
|
|
95
|
+
c.ID = 'conv-new';
|
|
96
|
+
created.conversation.push(c);
|
|
97
|
+
return c;
|
|
98
|
+
}),
|
|
99
|
+
} as unknown as IMetadataProvider;
|
|
100
|
+
return { provider, created };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const egress: MeetingRecordingEgressResult = {
|
|
104
|
+
EgressID: 'eg-1',
|
|
105
|
+
RoomName: 'room-1',
|
|
106
|
+
OutputLocation: 'room-1/2026.mp4',
|
|
107
|
+
OutputSizeBytes: 1234,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
vi.unstubAllEnvs();
|
|
113
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [] });
|
|
114
|
+
storage.accountsByProvider = new Map([['prov-sink', [{ ID: 'acct-sink' }]]]);
|
|
115
|
+
process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER = 'prov-sink';
|
|
116
|
+
delete process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('registerMeetingRecordingFile', () => {
|
|
120
|
+
it('resolves the conversation, creates the Files row pointing at the sink, and stamps the Conversation', async () => {
|
|
121
|
+
// First RunView (by EgressID) → no match; second (by RoomName) → an existing conversation.
|
|
122
|
+
runViewMock
|
|
123
|
+
.mockResolvedValueOnce({ Success: true, Results: [] })
|
|
124
|
+
.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-existing' }] });
|
|
125
|
+
|
|
126
|
+
const { provider, created } = makeProvider();
|
|
127
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
128
|
+
|
|
129
|
+
expect(result.Success).toBe(true);
|
|
130
|
+
expect(result.RecordingFileID).toBe('file-123');
|
|
131
|
+
expect(result.ConversationID).toBe('conv-existing');
|
|
132
|
+
|
|
133
|
+
// Files row: correct provider/key/content-type/status, pointing directly at the egress output.
|
|
134
|
+
const file = created.file[0];
|
|
135
|
+
expect(file.fields.ProviderID).toBe('prov-sink');
|
|
136
|
+
expect(file.fields.ProviderKey).toBe('room-1/2026.mp4');
|
|
137
|
+
expect(file.fields.ContentType).toBe('video/mp4');
|
|
138
|
+
expect(file.fields.Status).toBe('Uploaded');
|
|
139
|
+
expect(file.saved).toBe(true);
|
|
140
|
+
|
|
141
|
+
// Conversation stamped with the file id (loaded by id, then saved).
|
|
142
|
+
const conv = created.conversation[0];
|
|
143
|
+
expect(conv.loadedID).toBe('conv-existing');
|
|
144
|
+
expect(conv.fields.RecordingFileID).toBe('file-123');
|
|
145
|
+
expect(conv.fields.EgressID).toBe('eg-1');
|
|
146
|
+
expect(conv.saved).toBe(true);
|
|
147
|
+
|
|
148
|
+
// No byte copy in the default v1 path.
|
|
149
|
+
expect(storage.getObject).not.toHaveBeenCalled();
|
|
150
|
+
expect(storage.uploadFile).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('prefers matching the conversation by EgressID when present', async () => {
|
|
154
|
+
runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-by-egress' }] });
|
|
155
|
+
const { provider } = makeProvider();
|
|
156
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
157
|
+
expect(result.ConversationID).toBe('conv-by-egress');
|
|
158
|
+
// Only the EgressID lookup ran (no fall-through to room-name lookup).
|
|
159
|
+
expect(runViewMock).toHaveBeenCalledTimes(1);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('creates a Meeting-Room Conversation when none exists', async () => {
|
|
163
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [] }); // neither lookup matches
|
|
164
|
+
const { provider, created } = makeProvider();
|
|
165
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
166
|
+
expect(result.Success).toBe(true);
|
|
167
|
+
const conv = created.conversation[0];
|
|
168
|
+
expect(conv.fields.Type).toBe('Meeting Room');
|
|
169
|
+
expect(conv.fields.ExternalID).toBe('room-1');
|
|
170
|
+
expect(conv.fields.ApplicationScope).toBe('Application');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('returns a graceful failure when the storage provider is not configured', async () => {
|
|
174
|
+
delete process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER;
|
|
175
|
+
const { provider } = makeProvider();
|
|
176
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
177
|
+
expect(result.Success).toBe(false);
|
|
178
|
+
expect(result.ErrorMessage).toMatch(/MJ_MEETING_RECORDING_STORAGE_PROVIDER/);
|
|
179
|
+
expect(result.RecordingFileID).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('returns a graceful failure when no account is linked to the configured provider', async () => {
|
|
183
|
+
storage.accountsByProvider = new Map(); // no accounts for prov-sink
|
|
184
|
+
const { provider } = makeProvider();
|
|
185
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
186
|
+
expect(result.Success).toBe(false);
|
|
187
|
+
expect(result.ErrorMessage).toMatch(/No active MJStorage account/i);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('returns a graceful failure when the egress has no output yet', async () => {
|
|
191
|
+
const { provider } = makeProvider();
|
|
192
|
+
const result = await registerMeetingRecordingFile({ ...egress, OutputLocation: undefined }, user, provider);
|
|
193
|
+
expect(result.Success).toBe(false);
|
|
194
|
+
expect(result.ErrorMessage).toMatch(/no output/i);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe('copy-to-canonical (the "copy into Box" option)', () => {
|
|
198
|
+
it('copies the MP4 into a differing canonical provider and points the Files row there', async () => {
|
|
199
|
+
storage.accountsByProvider = new Map([
|
|
200
|
+
['prov-sink', [{ ID: 'acct-sink' }]],
|
|
201
|
+
['prov-canonical', [{ ID: 'acct-canonical' }]],
|
|
202
|
+
]);
|
|
203
|
+
process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER = 'prov-canonical';
|
|
204
|
+
runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
|
|
205
|
+
|
|
206
|
+
const { provider, created } = makeProvider();
|
|
207
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
208
|
+
|
|
209
|
+
expect(result.Success).toBe(true);
|
|
210
|
+
// Bytes were read from the sink and uploaded into the canonical provider.
|
|
211
|
+
expect(storage.getObject).toHaveBeenCalledWith({ fullPath: 'room-1/2026.mp4' });
|
|
212
|
+
expect(storage.uploadFile).toHaveBeenCalledOnce();
|
|
213
|
+
|
|
214
|
+
// The Files row points at the canonical provider + its uploaded key.
|
|
215
|
+
const file = created.file[0];
|
|
216
|
+
expect(file.fields.ProviderID).toBe('prov-canonical');
|
|
217
|
+
expect(file.fields.ProviderKey).toBe('meeting-recordings/2026/x.mp4');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('does NOT copy when the canonical provider equals the sink provider', async () => {
|
|
221
|
+
process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER = 'prov-sink'; // same as sink
|
|
222
|
+
runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
|
|
223
|
+
const { provider, created } = makeProvider();
|
|
224
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
225
|
+
expect(result.Success).toBe(true);
|
|
226
|
+
expect(storage.getObject).not.toHaveBeenCalled();
|
|
227
|
+
expect(created.file[0].fields.ProviderID).toBe('prov-sink');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('fails gracefully (no throw) when the Files row save fails', async () => {
|
|
232
|
+
runViewMock.mockResolvedValueOnce({ Success: true, Results: [{ ID: 'conv-1' }] });
|
|
233
|
+
const { provider } = makeProvider({ fileSaveOk: false });
|
|
234
|
+
const result = await registerMeetingRecordingFile(egress, user, provider);
|
|
235
|
+
expect(result.Success).toBe(false);
|
|
236
|
+
expect(result.ErrorMessage).toMatch(/Files row/i);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('resolveMeetingConversation', () => {
|
|
241
|
+
it('falls back to creating a conversation when neither lookup matches', async () => {
|
|
242
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [] });
|
|
243
|
+
const { provider, created } = makeProvider();
|
|
244
|
+
const id = await resolveMeetingConversation(egress, user, provider);
|
|
245
|
+
expect(id).toBe('conv-new');
|
|
246
|
+
expect(created.conversation).toHaveLength(1);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -181,6 +181,9 @@ export class SessionManager {
|
|
|
181
181
|
AgentID: session.AgentID,
|
|
182
182
|
UserID: session.UserID,
|
|
183
183
|
ConversationID: session.ConversationID ?? null,
|
|
184
|
+
// Hand the verbatim per-session config blob to data-aware channels (e.g. the Media
|
|
185
|
+
// channel reads a per-session mediaCollectionID override from it). Opaque here.
|
|
186
|
+
AgentSessionConfig: session.Config_ ?? null,
|
|
184
187
|
},
|
|
185
188
|
contextUser,
|
|
186
189
|
provider,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module @memberjunction/server
|
|
9
9
|
*/
|
|
10
|
-
import { LoadWhiteboardChannelServer, LoadMeetingControlsChannelServer } from '@memberjunction/ai-agents';
|
|
10
|
+
import { LoadWhiteboardChannelServer, LoadMeetingControlsChannelServer, LoadMediaChannelServer, LoadClientContextChannelServer } from '@memberjunction/ai-agents';
|
|
11
11
|
import { LoadRemoteBrowserChannel } from '@memberjunction/remote-browser-server';
|
|
12
12
|
import { LoadSelfHostRemoteBrowser } from '@memberjunction/remote-browser-selfhost';
|
|
13
13
|
import { BindRemoteBrowserGoalEngine } from './remoteBrowserGoalEngine.js';
|
|
@@ -17,6 +17,8 @@ import { BindRemoteBrowserGoalEngine } from './remoteBrowserGoalEngine.js';
|
|
|
17
17
|
// the session lifecycle — `SessionManager.CreateSession` resolves them via the ClassFactory.
|
|
18
18
|
LoadWhiteboardChannelServer();
|
|
19
19
|
LoadMeetingControlsChannelServer();
|
|
20
|
+
LoadMediaChannelServer();
|
|
21
|
+
LoadClientContextChannelServer();
|
|
20
22
|
// Remote Browser native channel (client-direct): the lifecycle-only server channel plugin + the
|
|
21
23
|
// Self-Hosted Chrome backend driver (whose default runner launches a local headless Chromium via
|
|
22
24
|
// Playwright — pulled in transitively through the SelfHost package, documented and acceptable).
|