This commit is contained in:
Seth Call 2026-03-02 08:35:23 -06:00
parent 61a7ed223b
commit 4eab4ce75e
11 changed files with 124929 additions and 1 deletions

3
.gitignore vendored
View File

@ -11,4 +11,5 @@ working.png
ruby/.rails5-gems ruby/.rails5-gems
web/.rails5-gems web/.rails5-gems
websocket-gateway/.rails5-gems websocket-gateway/.rails5-gems
.pg_data/ .pg_data/
test-results

View File

@ -0,0 +1,92 @@
# WebRTC Web Client Plan Progress
## Goal
Implement browser-native WebRTC media for "web client mode" while preserving the existing frontend/backend control-plane behavior (bridge call sequencing, REST patterns, websocket message flow, and session state transitions) as closely as possible to native.
## Baseline Artifacts
- Native 2-party references:
- `web/ai/native-client-2p-recording/20260228-210957-seth-native-2p-mute-volume.log`
- `web/ai/native-client-2p-recording/20260228-211006-david-native-2p-mute-volume.log`
- High-signal baseline from these logs:
- `join-dedupe.create` once and `join-dedupe.release` once per join.
- one `POST /api/sessions/:id/participants` per client join.
- repeated `PUT /api/sessions/:id/tracks` during mute/volume operations.
- heavy `jamClient` polling/queries (`getConnectionDetail`, `SessionGetAllControlState`, `SessionSetControlState`, `FTUEGetChannels`, `P2PMessageReceived`) that inform adapter contract priorities.
## Compatibility Contract (for web-client mode)
- Preserve:
- action-level call sequence (`SessionActions` -> `SessionStore` -> rest/websocket/adapter)
- REST endpoint/method cadence for session join + track control
- websocket gateway usage and event routing pattern
- participant/session state behavior in frontend stores
- Allow controlled divergence:
- bridge methods that are native-only may return `null`/stub values in web mode if callers are guarded by `isWebClient`/`isNativeClient` aware logic.
- media transport and signaling payload internals may differ for web-only peers.
## Execution Plan
### 1) Contract Matrix (bridge + REST + websocket) [done]
- Build a method matrix from recordings:
- Tier A (must implement behavior): session join/leave, participant lifecycle, mute/volume/control-state methods, callback registration methods used in session runtime.
- Tier B (must exist, can be stubbed): profile/device/vst/native diagnostics that do not affect core web-to-web media.
- Tier C (deferred): legacy recording/VST branches being deprecated.
- Output: `web/ai/native-client-2p-recording/contract-matrix.md`.
### 2) WebJamClient Core (control plane parity)
- Expand `WebJamClient` to provide Tier A method behavior with deterministic return shapes.
- Maintain adapter-level instrumentation in `jamClientAdapter` for parity diffing.
- Keep method names and call sites unchanged wherever possible.
### 3) WebRTC Session Media Engine (data plane)
- Add web-client media manager:
- local mic capture lifecycle
- peer connection lifecycle keyed by participant `client_id`
- mute/volume mapping between UI track state and WebRTC tracks/gain nodes
- VU meter signal path for local/remote tracks
- Use existing gateway/P2P channel pattern for signaling envelopes in web mode.
### 4) Track-Control Parity
- Ensure UI interactions (self mute, other mute, self volume, other volume) continue to drive:
- same store/action flow
- same `PUT /api/sessions/:id/tracks` pattern
- same session refresh behavior (`track_changes_counter` driven updates)
### 5) Automated Compatibility Assertions
- Add a test harness that compares captured run artifacts against compatibility rules:
- join count and participant POST cardinality
- REST endpoint/method sequence constraints (tolerant matching where needed)
- required bridge method call presence/order windows for core flows
- required websocket event classes
- Keep this as "pattern parity" (not strict payload byte-equality).
### 6) End-to-End Validation Runs
- Run 2-party web-client scenario (same script as native baseline):
- self mute/unmute
- other mute/unmute
- self volume min/max
- other volume min/max
- Compare artifacts and close gaps before widening feature scope.
## Status
- [x] Baseline native 2-party logs captured and reviewed
- [x] Contract matrix drafted (Tier A/B/C)
- [~] WebJamClient Tier A behavior implemented (initial control-state + session/participant + p2p/webrtc signaling scaffolding in place)
- [ ] WebRTC peer/media manager integrated
- [ ] Track control parity verified against baseline patterns
- [ ] Compatibility assertions added
- [ ] 2-party web-client parity run completed
## 2026-03-01 Implementation Update
- Implemented first Tier A `WebJamClient` pass in `web/app/assets/javascripts/webJamClient.js`:
- Added control-state cache for `SessionGetAllControlState(master|personal)` seeded from delegate then locally updated.
- Added `SessionSetControlState` local mutation from `trackVolumeObject` and broadcast-aware `MixerActions.syncTracks()` scheduling to preserve REST `/tracks` flow in web mode.
- Added session/participant lifecycle state updates in:
- `UpdateSessionInfo`
- `ParticipantJoined` / `ParticipantLeft`
- `ClientJoinedSession` / `ClientLeftSession`
- Added initial WebRTC signaling scaffolding (feature-flagged):
- peer connection registry
- offer/answer/candidate handling over existing websocket-gateway P2P path (`JK.JamServer.sendP2PMessage`)
- inbound signal handling in `P2PMessageReceived`
- local mic stream bootstrap for web-client WebRTC mode.
- This is intentionally incremental; next step is validating 2-party web-client flow and closing gaps in remote audio rendering/mix handling.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,72 @@
# Native 2P Contract Matrix (Join + Mute/Volume)
Sources:
- `web/ai/native-client-2p-recording/20260228-210957-seth-native-2p-mute-volume.log`
- `web/ai/native-client-2p-recording/20260228-211006-david-native-2p-mute-volume.log`
## Intent
Define what web-client mode must preserve vs what can diverge while keeping frontend/backend control-plane behavior aligned with native.
## Tier A: Must Implement (Behavioral)
These methods/events directly participate in join/session lifecycle, participant presence, and mute/volume controls.
| Surface | Item | Baseline Evidence | Web-client Requirement |
|---|---|---|---|
| jamClient method | `JoinSession` | seen once per join | Must execute and transition session state; trigger equivalent downstream flow |
| jamClient method | `LeaveSession` | seen on session exit | Must execute and clean state/media |
| jamClient method | `SessionPageEnter` | seen on session page load | Must return compatible object shape for existing guards |
| jamClient method | `SessionRegisterCallback` | seen before runtime events | Must store callback name/handler and route bridge events |
| jamClient method | `RegisterSessionJoinLeaveRequestCallBack` | seen in david run | Must preserve callback registration behavior |
| jamClient method | `ParticipantJoined` / `ParticipantLeft` | seen in session lifecycle | Must keep participant state transitions consistent |
| jamClient method | `ClientJoinedSession` / `ClientLeftSession` | seen in david run | Must preserve per-client lifecycle notifications |
| jamClient method | `SessionGetAllControlState` | 49/71 calls | Must provide control-state snapshot consumed by UI/stores |
| jamClient method | `SessionSetControlState` | 54/46 calls | Must apply mute/volume changes and keep UI+server pattern aligned |
| jamClient method | `getConnectionDetail` | high-frequency polling | Must provide values needed by connection UI/presence logic |
| jamClient method | `P2PMessageReceived` | 30/32 calls | Must process incoming signaling/control payloads |
| jamClient method | `UpdateSessionInfo` | periodic updates | Must keep session metadata refresh behavior |
| jamClient method | `SessionSetConnectionStatusRefreshRate` | seen twice each | Must preserve periodic status cadence behavior |
| REST | `POST /api/sessions/:id/participants` | once per join (both logs) | Must remain one POST per join action |
| REST | `PUT /api/sessions/:id/tracks` | repeated during mute/volume ops | Must keep same endpoint/method semantics for track control |
| REST | `GET /api/sessions/:id` | repeated polling/refresh | Must maintain session refresh cadence compatible with stores |
| REST | `GET /api/sessions/:id/history` | seen once | Must preserve join/session hydrate flow |
| REST | `DELETE /api/participants/:client_id` | seen once per leave | Must preserve leave teardown semantics |
| websocket receive | `PEER_MESSAGE` | dominant event type | Must preserve message class and dispatch path |
| websocket receive | `TRACKS_CHANGED` | seen during control updates | Must preserve track refresh trigger behavior |
| websocket receive | `SESSION_JOIN` / `SESSION_DEPART` | seen during presence changes | Must preserve participant presence events |
| websocket send | `PEER_MESSAGE` | dominant outbound type | Must preserve signaling/control channel pattern |
## Tier B: Must Exist, Stub/Approximation Acceptable
These are invoked but not critical to core web-to-web mute/volume MVP.
- `IsNativeClient` (web mode should return `false`; callsite guards required)
- `getClientParentChildRole`, `getParentClientId`
- `SessionSetUserName`, `LastUsedProfileName`, `SetLastUsedProfileName`
- `FTUEGetExpectedLatency`, `FTUEGetChannels`, `FTUEGetAllAudioConfigurations`
- `FTUEGetGoodAudioConfigurations`, `FTUEGetConfigurationDevice`, `FTUEGetChatInputVolume`
- `FTUECurrentSelectedVideoDevice`, `FTUEGetVideoCaptureDeviceNames`, `FTUEGetVideoShareEnable`
- `FTUEGetStatus`, `FTUEGetCaptureResolution`, `FTUEGetCurrentCaptureResolution`
- `GetNetworkTestScore`, `NetworkTestResult`, `GetOSAsString`, `getOperatingMode`
- `SessionSetAlertCallback`, `RegisterVolChangeCallBack`, `setMetronomeOpenCallback`
- `OnLoggedIn`, `ClientUpdateVersion`, `ResetPageCounters`, `ReloadAudioSystem`, `SetLatencyTestBlocked`, `SetVURefreshRate`, `SetScoreWorkTimingInterval`, `SessionGetMacHash`, `IsAudioStarted`, `IsAppInWritableVolume`
## Tier C: Deferred / Native-Only (safe to keep null/no-op in web mode)
- VST and MIDI surface:
- `hasVstAssignment`, `IsVstLoaded`, `VSTListSearchPaths`, `VSTListTrackAssignments`, `VSTListVsts`, `VST_GetMidiDeviceList`, `VST_ScanForMidiDevices`
- Legacy recording-manager hooks not needed for current web MVP:
- `RegisterRecordingCallbacks`, `RegisterRecordingManagerCallbacks`
## Parity Rules for Automated Comparison
1. Join cardinality: exactly one `join-dedupe.create` and one participant `POST` per user join action.
2. No duplicate in-flight join: no `join-dedupe.reuse` during normal single-click join.
3. Track-control behavior: mute/volume actions must produce `PUT /api/sessions/:id/tracks` updates and corresponding `TRACKS_CHANGED`/refresh behavior.
4. Control-plane continuity: websocket `PEER_MESSAGE` send/receive remains active during session runtime.
5. Leave behavior: one participant `DELETE` on leave and participant/session teardown events observed.
## Immediate Build Order
1. Implement Tier A `WebJamClient` methods with stable return shapes.
2. Integrate WebRTC signaling over existing `PEER_MESSAGE` path for web-client mode.
3. Wire mute/volume UI actions to existing track control flow (`SessionSetControlState` -> REST + state updates).
4. Add parity checks against rules above using collected debug-log artifacts.

View File

@ -0,0 +1,24 @@
We need to make sure that our Recurly based tracking and subscription management is *rock solid*.
First, do a comphrensive sweep of the code, and understand:
All use of the recurly client.
Note where we make changes to a subscription from the user making payment input or subscription changes in the UI, focusing on the api_recurly_controller.rb in `web`.
Note in `ruby` we should have what's called a HourlyJob (hourly_job.rb), and note that there is some processing related to recurly there; this HourlyJob is all important for synchronizing the state of our server to recurly, as subscirptions expire/renew etc, on Recurly's side.
Then look for all tests. Please literally list every test, and what it verifies. We should then focus on testing what the gap. In particular, we should test happy-path and non-happy-path recurly lifecycle stuff:
1. User enters payment and makes a non-free subscription choice (like silver, gold, platinum). Note that we immediately contact Recurly and attempt to trigger a payment of the user's card at that time. Does this work in a test?
1a. Note that the UI deliberately makes it so you can enter payment first, and then select a non-free subscription, or, do it in the opposite order. Both should trigger the same ultimate decision to attempt to charge the user and start their subscription
2. Then, we need to ensure that the recurly background job, in the hourly_job.rb, works. Meaning, if the state has not changed, that the job reflects that. If the user has cancelled their subscription, it should refelct that. If a month has gone by, and their payment no longer works, we should reflect that (this may require some clever TimeCop and recurly API usage to simulate passage-of-time tests).
3. We need to also be sure that the first-free month of a gold tier plan works as expected, with the passage of time. (may be tested already, but if not, we need to add tests).
In the end, we should have strong tests that use a browser for where it makes sense, and we should definitely have jobs that run the hourly job and change state on Recurly's side to provoke the various things a user's account may do (stop having valid payment, be canceled on the user's side, and so on).
1. First, let's scan the code and draw up a new folder called `jam-cloud/docs/dev/subscriptions.md`, and it should summarize how all of the code works now. And talk about how it's tested now.
2. We then create a jam-cloud/web/ai/tasks/test-subscriptions-plan.md, where we talk abuot all the tests we yet need to write.
3. Then we write all the tests needed to prove the feature works. Note: the feature works *very well* in production, however, that's using Rails 3 and we have migrated to Rails 8 on this branch, and we may have a regression or two. But Probably not, so if a test finds an error, we should first doubt the test, note the code. Or ask me to inspect the test failure and provide help.
4. Update docs/dev/subscriptions.md when done, to include references to the new tests. Finally, some tests should go in ruby/specs, some in web/specs. ruby tests models/clients etc, web tests controllers/UI and implicitely models too.

View File

@ -0,0 +1,104 @@
(function(context, $) {
"use strict";
context.JK = context.JK || {};
function readCookie(name) {
var cookie = (document.cookie || "").split(";").map(function(v) { return v.trim(); })
.filter(function(v) { return v.indexOf(name + "=") === 0; })[0];
if (!cookie) { return null; }
return decodeURIComponent(cookie.substring(name.length + 1));
}
function localStorageFlag(key) {
try {
if (!context.localStorage) { return null; }
return context.localStorage.getItem(key);
} catch (e) {
return "unavailable";
}
}
function adapterMode() {
var adapter = context.jamClientAdapter;
if (!adapter) { return "none"; }
if (adapter.__jkJamClientAdapter) { return "native-adapter"; }
if (adapter.IsWebClient && adapter.IsWebClient()) { return "web-jam-client"; }
return "direct";
}
function boolLabel(v) {
return v ? "on" : "off";
}
function buildLines() {
var gon = context.gon || {};
var hasJamClient = !!context.jamClient;
var hasAdapter = !!context.jamClientAdapter;
var adapter = context.jamClientAdapter;
var adapterIsNative = false;
var adapterIsWeb = false;
try {
adapterIsNative = !!(adapter && adapter.IsNativeClient && adapter.IsNativeClient());
} catch (e) {}
try {
adapterIsWeb = !!(adapter && adapter.IsWebClient && adapter.IsWebClient());
} catch (e) {}
return [
"mode-flags",
"legacy_qt_webkit_client: " + boolLabel(!!gon.legacy_qt_webkit_client),
"gon.isNativeClient: " + boolLabel(!!gon.isNativeClient),
"cookie act_as_native_client: " + ((readCookie("act_as_native_client") === "true") ? "on" : "off"),
"ls jk.webClient.webrtc: " + (localStorageFlag("jk.webClient.webrtc") === "1" ? "on" : "off"),
"ls jk.nativeJamClient.recording: " + (localStorageFlag("jk.nativeJamClient.recording") === "1" ? "on" : "off"),
"jamClient present: " + boolLabel(hasJamClient),
"jamClientAdapter present: " + boolLabel(hasAdapter),
"adapter mode: " + adapterMode(),
"adapter IsNativeClient(): " + boolLabel(adapterIsNative),
"adapter IsWebClient(): " + boolLabel(adapterIsWeb)
];
}
function renderOverlay($el) {
$el.text(buildLines().join(" | "));
}
function ensureOverlay() {
var gon = context.gon || {};
if (!gon.show_mode_flags_overlay) { return; }
var $overlay = $("#jk-mode-flags-overlay");
if ($overlay.length === 0) {
$overlay = $("<div id=\"jk-mode-flags-overlay\"></div>");
$overlay.css({
position: "fixed",
top: "2px",
left: "2px",
zIndex: 999999,
background: "rgba(0,0,0,0.80)",
color: "#8dff8d",
fontFamily: "monospace",
fontSize: "10px",
lineHeight: "1.3",
padding: "3px 6px",
borderRadius: "3px",
pointerEvents: "none",
maxWidth: "98vw",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
});
$("body").append($overlay);
}
renderOverlay($overlay);
context.setInterval(function() {
renderOverlay($overlay);
}, 1000);
}
$(ensureOverlay);
})(window, jQuery);

2940
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,51 @@
const { defineConfig, devices } = require('@playwright/test');
const baseURL = process.env.FRONTEND_URL || 'http://www.jamkazam.test:3000';
const browserName = process.env.BROWSER || 'chromium';
module.exports = defineConfig({
testDir: '.',
testMatch: ['*.spec.js'],
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
timeout: 120000,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-ui-for-media-stream',
'--use-fake-device-for-media-stream',
'--no-sandbox',
],
},
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
launchOptions: {
firefoxUserPrefs: {
'media.navigator.streams.fake': false,
'media.navigator.permission.disabled': true,
'media.autoplay.default': 0,
'media.peerconnection.ice.loopback': true,
},
},
},
},
].filter((project) => project.name === browserName),
});

View File

@ -0,0 +1,197 @@
const { test, expect } = require('@playwright/test');
const APP_ORIGIN = process.env.FRONTEND_URL || 'http://www.jamkazam.test:3000';
const APP_HOST = new URL(APP_ORIGIN).hostname;
// Default from active local logs; can be overridden at runtime:
// REMEMBER_TOKEN=... npx playwright test ...
const REMEMBER_TOKEN = process.env.REMEMBER_TOKEN || 'xAkhA3BiUZTjTM7hovMP_g';
test.describe('Quick Start Private Session', () => {
test('enters session and stays there (no immediate auto-leave)', async ({ page, context }) => {
const jsErrors = [];
const consoleErrors = [];
const participantDeletes = [];
page.on('pageerror', (err) => {
jsErrors.push(String(err));
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
page.on('request', (request) => {
const method = request.method();
const url = request.url();
if (method === 'DELETE' && /\/api\/participants\//.test(url)) {
const event = { method, url, ts: new Date().toISOString() };
participantDeletes.push(event);
page.evaluate((e) => {
if (!window.__jkParticipantDeleteRequests) window.__jkParticipantDeleteRequests = [];
window.__jkParticipantDeleteRequests.push(e);
}, event).catch(() => {});
}
});
// Must exist before first request so server computes gon.isNativeClient=true.
await context.addCookies([
{
name: 'remember_token',
value: REMEMBER_TOKEN,
domain: APP_HOST,
path: '/',
httpOnly: false,
secure: false,
},
{
name: 'act_as_native_client',
value: 'true',
domain: APP_HOST,
path: '/',
httpOnly: false,
secure: false,
},
]);
await context.addInitScript(() => {
try {
window.localStorage.setItem('jk.webClient.webrtc', '1');
} catch (e) {
// ignore
}
// Test-only instrumentation: capture leaveSession callers with stack.
window.__jkLeaveActionTraces = [];
window.__jkParticipantDeleteRequests = [];
const patchLeaveAction = () => {
const actions = window.SessionActions;
if (!actions || !actions.leaveSession) return;
const action = actions.leaveSession;
if (action.__jkPlaywrightPatched) return;
const pushTrace = (source, argsLike) => {
const args = Array.prototype.slice.call(argsLike || []);
window.__jkLeaveActionTraces.push({
ts: new Date().toISOString(),
source,
args,
stack: (new Error('playwright leaveSession trace')).stack,
});
};
const wrapped = function wrappedLeaveAction(...args) {
pushTrace('call', args);
return action.apply(this, args);
};
Object.assign(wrapped, action);
if (typeof action.trigger === 'function') {
const originalTrigger = action.trigger.bind(action);
wrapped.trigger = (...args) => {
pushTrace('trigger', args);
return originalTrigger(...args);
};
}
wrapped.__jkPlaywrightPatched = true;
actions.leaveSession = wrapped;
};
setInterval(patchLeaveAction, 100);
});
let joinedUrl = null;
try {
await page.goto('/client#', { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => {
return !!(window.gon && window.gon.isNativeClient);
}, { timeout: 20000 });
await expect(page.locator('h2', { hasText: /create session/i })).toBeVisible({ timeout: 20000 });
await page.locator('.createsession').first().click();
const quickStartSolo = page.locator('.quick-start-solo', { hasText: /quick start private/i });
await expect(quickStartSolo).toBeVisible({ timeout: 20000 });
await quickStartSolo.click();
await page.waitForURL(/\/client#\/session\/[0-9a-f-]+/i, { timeout: 30000 });
joinedUrl = page.url();
const myTrack = page.locator('#session-screen .session-my-tracks .session-track.my-track');
await expect(myTrack).toBeVisible({ timeout: 15000 });
// Guard against immediate auto-leave (the current regression).
await page.waitForTimeout(7000);
const leaveTraces = await page.evaluate(() => window.__jkLeaveActionTraces || []);
await expect(page).toHaveURL(/\/client#\/session\/[0-9a-f-]+/i);
expect(page.url(), 'URL changed after join').toBe(joinedUrl);
expect(
participantDeletes,
`Unexpected DELETE /api/participants requests: ${JSON.stringify(participantDeletes, null, 2)}\nleave traces=${JSON.stringify(leaveTraces, null, 2)}`
).toHaveLength(0);
expect(jsErrors, `Page JS errors: ${JSON.stringify(jsErrors, null, 2)}`).toEqual([]);
} finally {
const diagnostics = await page.evaluate(() => {
const collector = window.JK && window.JK.DebugLogCollector;
return {
leaveActionTraces: window.__jkLeaveActionTraces || [],
joinAborts: window.__jkJoinAborts || [],
participantDeleteRequests: window.__jkParticipantDeleteRequests || [],
debugCollectorBuffer: (collector && collector.getBuffer && collector.getBuffer()) || [],
url: window.location.href,
};
}).catch(() => ({
leaveActionTraces: [],
joinAborts: [],
participantDeleteRequests: [],
debugCollectorBuffer: [],
url: page.url(),
}));
await test.info().attach('leave-traces.json', {
body: JSON.stringify(diagnostics.leaveActionTraces, null, 2),
contentType: 'application/json',
});
await test.info().attach('participant-delete-requests.json', {
body: JSON.stringify(diagnostics.participantDeleteRequests, null, 2),
contentType: 'application/json',
});
await test.info().attach('join-aborts.json', {
body: JSON.stringify(diagnostics.joinAborts, null, 2),
contentType: 'application/json',
});
await test.info().attach('debug-log-collector-buffer.json', {
body: JSON.stringify(diagnostics.debugCollectorBuffer, null, 2),
contentType: 'application/json',
});
await test.info().attach('browser-errors.json', {
body: JSON.stringify({ jsErrors, consoleErrors, participantDeletes, joinedUrl, finalUrl: diagnostics.url }, null, 2),
contentType: 'application/json',
});
// Emit to stdout for quick local triage when attachments are inconvenient to inspect.
// eslint-disable-next-line no-console
console.log('PW_DIAGNOSTICS_START');
// eslint-disable-next-line no-console
console.log(JSON.stringify({
leaveActionTraces: diagnostics.leaveActionTraces,
joinAborts: diagnostics.joinAborts,
participantDeleteRequests: diagnostics.participantDeleteRequests,
debugCollectorTail: diagnostics.debugCollectorBuffer.slice(-40),
jsErrors,
consoleErrors,
participantDeletes,
joinedUrl,
finalUrl: diagnostics.url,
}));
// eslint-disable-next-line no-console
console.log('PW_DIAGNOSTICS_END');
}
});
});