0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-09 21:45:25 +08:00
discourse/plugins/discourse-calendar/test/javascripts/integration/components/zoom-page-test.gjs
Martin Brennan 2ff5b99a4d
FEATURE: Embedded Zoom webinar for livestream events (#40973)
This commit adds support for embedding a Zoom webinar
livestream directly within a Discourse Event via a URL in
the location field of the event in the topic.

Admins must supply a Zoom API key and secret in the plugin
settings for this functionality to be available using
these settings:

* livestream_zoom_enabled
* livestream_zoom_sdk_key
* livestream_zoom_sdk_secret

When a topic is opened with a Zoom URL in the event location,
and the Livestream toggle is enabled, the
Zoom library dependencies will be loaded asyncrhonously
and the Zoom webinar will be embedded within the topic page
using the Zoom component view.

On mobile, a fullscreen Zoom client using the Zoom client view
will be used, with a floating Chat button that opens the Discourse
livestream chat in a modal which slides up from the bottom
of the screen in an overlay. This client view is at `/t/:id/:slug/zoom` 

### Webinar event date/time not reached

<img width="880" height="612" alt="image"
src="https://github.com/user-attachments/assets/4b112b59-eed0-4d63-b166-da825d8ebd0b"
/>

### Within 30 minutes before and 10 minutes after the event date/time

<img width="879" height="613" alt="image"
src="https://github.com/user-attachments/assets/373191f8-6245-4b41-b2a8-161c935166be"
/>

### Attendee joined before host, we will retry join every 30s

<img width="735" height="817" alt="image"
src="https://github.com/user-attachments/assets/519afa51-8099-4af0-954f-08da4c7e3297"
/>

### Attendee joined

<img width="733" height="831" alt="image"
src="https://github.com/user-attachments/assets/f633d21f-956e-4bec-a2f8-f7dc8b95d974"
/>

---------

Co-authored-by: David Taylor <david@taylorhq.com>
Co-authored-by: awesomerobot <kris.aubuchon@discourse.org>
2026-07-16 10:58:56 +10:00

183 lines
6 KiB
Text
Vendored

import { getOwner } from "@ember/owner";
import { render } from "@ember/test-helpers";
import { module, test } from "qunit";
import sinon from "sinon";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import LivestreamZoomPage from "../../discourse/components/livestream/zoom-page";
const FALLBACK_SELECTOR = ".discourse-calendar-livestream-zoom-page__fallback";
const FRAME_SELECTOR = ".discourse-calendar-livestream-zoom-page__frame";
const CHAT_BUTTON_SELECTOR =
".discourse-calendar-livestream-zoom-page__chat-button";
const WAITING_SELECTOR = ".discourse-calendar-livestream-zoom-page__waiting";
const ERROR_TEXT =
"You left the webinar or we are unable to load Zoom in this page.";
// `returnedFromZoom` reads `window.location.search`, which a rendering test
// cannot set. Stubbing the getter is the only way to reach the branch.
function stubReturnedFromZoom(value) {
sinon.stub(LivestreamZoomPage.prototype, "returnedFromZoom").get(() => value);
}
// `loadZoom` is an `@action`, so the prototype holds an accessor rather than a
// plain method and a bare `sinon.stub` would leave the real one in place — it
// would reach for the network and the Zoom SDK.
function stubLoadZoom(implementation) {
const fake = sinon.fake(implementation ?? (() => Promise.resolve()));
sinon.stub(LivestreamZoomPage.prototype, "loadZoom").get(function () {
return fake.bind(this);
});
return fake;
}
module("Integration | Component | LivestreamZoomPage", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
getOwner(this).lookup("service:site-settings").chat_enabled = true;
this.topic = {
id: 1,
slug: "test-topic",
chat_channel_id: 9,
postStream: {
posts: [
{
event: {
id: 2,
creator: { id: 1, username: "test-user" },
livestream_url: "https://us06web.zoom.us/j/123456789",
starts_at: moment().subtract(5, "minutes").toISOString(),
ends_at: moment().add(1, "hour").toISOString(),
},
},
],
},
};
});
test("loads Zoom once into the frame", async function (assert) {
const loadZoom = stubLoadZoom();
stubReturnedFromZoom(false);
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(FRAME_SELECTOR).exists();
assert.dom(FALLBACK_SELECTOR).doesNotExist("no error before a failure");
assert.strictEqual(loadZoom.callCount, 1, "sets the SDK up exactly once");
});
// Simulates navigating straight to /t/:slug/:id/zoom before the join window
// opens, bypassing the disabled button on the topic page.
test("does not load Zoom before the event timeframe", async function (assert) {
const loadZoom = stubLoadZoom();
stubReturnedFromZoom(false);
this.topic.postStream.posts[0].event.starts_at = moment()
.add(2, "hours")
.toISOString();
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(FRAME_SELECTOR).doesNotExist();
assert
.dom(WAITING_SELECTOR)
.includesText("You can join the webinar closer to the event start time");
assert
.dom(`${WAITING_SELECTOR} a`)
.hasText("View topic for the event")
.hasAttribute("href", "/t/test-topic/1")
.hasClass("raw-link");
assert.strictEqual(loadZoom.callCount, 0, "never sets the SDK up");
});
test("does not load Zoom after the event timeframe", async function (assert) {
const loadZoom = stubLoadZoom();
stubReturnedFromZoom(false);
this.topic.postStream.posts[0].event.starts_at = moment()
.subtract(3, "hours")
.toISOString();
this.topic.postStream.posts[0].event.ends_at = moment()
.subtract(1, "hour")
.toISOString();
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(FRAME_SELECTOR).doesNotExist();
assert.dom(WAITING_SELECTOR).exists();
assert.strictEqual(loadZoom.callCount, 0, "never sets the SDK up");
});
test("shows the fallback link when Zoom fails to load", async function (assert) {
stubLoadZoom(function () {
this.errorMessage = ERROR_TEXT;
return Promise.resolve();
});
stubReturnedFromZoom(false);
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(FALLBACK_SELECTOR).exists();
assert.dom(`${FALLBACK_SELECTOR} p`).hasText(ERROR_TEXT);
assert
.dom(`${FALLBACK_SELECTOR} .btn-primary`)
.doesNotExist("no retry button unless the user came back from Zoom");
});
test("offers a retry when the user has returned from Zoom", async function (assert) {
stubReturnedFromZoom(true);
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert
.dom(`${FALLBACK_SELECTOR} p`)
.hasText(ERROR_TEXT, "does not try to rejoin automatically");
assert.dom(`${FALLBACK_SELECTOR} .btn-primary`).hasText("Join Zoom");
});
test("offers to open the chat when the topic has a channel", async function (assert) {
stubLoadZoom();
stubReturnedFromZoom(false);
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(CHAT_BUTTON_SELECTOR).exists();
});
test("hides the chat button when chat is disabled", async function (assert) {
stubLoadZoom();
stubReturnedFromZoom(false);
getOwner(this).lookup("service:site-settings").chat_enabled = false;
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(CHAT_BUTTON_SELECTOR).doesNotExist();
});
test("hides the chat button when the topic has no channel", async function (assert) {
stubLoadZoom();
stubReturnedFromZoom(false);
this.topic.chat_channel_id = null;
await render(
<template><LivestreamZoomPage @topic={{this.topic}} /></template>
);
assert.dom(CHAT_BUTTON_SELECTOR).doesNotExist();
});
});