pause
This commit is contained in:
parent
61a7ed223b
commit
4eab4ce75e
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
|
@ -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),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue