Skip to content
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "synclounge",
"version": "6.4.10",
"version": "6.4.11",
"description": "Synced playback of Plex content",
"author": "Chris Alves",
"bin": "./server.js",
Expand Down
16 changes: 12 additions & 4 deletions src/__tests__/desktop-ui-controls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ describe('watch party opener', () => {
});

describe('up next cancellation', () => {
it('cancels the pending automatic playback through the remaining Cancel action', async () => {
it.each(['cancel', 'automatic', 'play now'])('handles next-episode playback: %s', async (mode) => {
vi.useFakeTimers();
const playNext = vi.fn();
const clearNext = vi.fn();
Expand All @@ -168,11 +168,19 @@ describe('up next cancellation', () => {
try {
await wrapper.vm.$nextTick();
const buttons = new DOMWrapper(document.body).findAll('.v-bottom-sheet button');
expect(buttons.map((button) => button.text())).toEqual(['Play Now', 'Cancel']);
await buttons[1].trigger('click');
expect(buttons.map((button) => button.text())).toEqual(['Play Now', 'Don’t play next episode']);
expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 10 seconds');
await vi.advanceTimersByTimeAsync(3000);
expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 7 seconds');
await vi.advanceTimersByTimeAsync(6000);
expect(new DOMWrapper(document.body).text()).toContain('Next episode starts in 1 second');
if (mode === 'automatic') await vi.advanceTimersByTimeAsync(1000);
else await buttons[mode === 'cancel' ? 1 : 0].trigger('click');
expect(clearNext).toHaveBeenCalledExactlyOnceWith(null);
expect(wrapper.vm.countdownIntervalId).toBe(null);
await vi.advanceTimersByTimeAsync(10001);
expect(playNext).not.toHaveBeenCalled();
if (mode === 'cancel') expect(playNext).not.toHaveBeenCalled();
else expect(playNext).toHaveBeenCalledTimes(1);
} finally {
wrapper.unmount();
vi.useRealTimers();
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/participant-health.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,16 @@ describe('participant timing across Plex servers', () => {
media: {},
})).toBe('Different media');
});
it('keeps unknown playback states connecting', () => {
expect(UserList.methods.readinessLabel.call(context, {}, 'viewer')).toBe('Connecting');
expect(UserList.methods.readinessLabel.call(context, { media }, 'viewer')).toBe('Connecting');
});
it('uses the viewer tolerance for basic status while retaining detailed drift', () => {
const basicContext = { ...context, driftLabel: UserList.methods.driftLabel.bind(context) };
const user = { media: context.GET_HOST_USER.media, state: 'playing', syncFlexibility: 3000 };
expect(UserList.methods.readinessLabel.call(basicContext, user, 'viewer')).toBe('In sync');
expect(UserList.methods.readinessLabel.call(basicContext, { ...user, syncFlexibility: 500 }, 'viewer'))
.toBe('Catching up');
expect(UserList.methods.driftLabel.call(context, user)).toBe('2.0s behind');
});
});
121 changes: 121 additions & 0 deletions src/__tests__/participant-moderation.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
describe, expect, it, vi,
} from 'vitest';
import { mount, flushPromises, DOMWrapper } from '@vue/test-utils';
import { createStore } from 'vuex';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import UserList from '@/components/UserList.vue';

vi.stubGlobal('visualViewport', {
width: 1280, height: 800, addEventListener() {}, removeEventListener() {},
});

const media = {
type: 'movie', title: 'Movie', machineIdentifier: 'one', ratingKey: '1',
};
const participant = (username, extra = {}) => ({
username, state: 'playing', media, time: 10000, updatedAt: Date.now(), playbackRate: 1, duration: 60000, ...extra,
});
const setup = (friendOptions = {}) => {
const kick = vi.fn().mockResolvedValue();
const transfer = vi.fn().mockResolvedValue();
const store = createStore({
getters: { GET_CONFIG: () => ({ sidebar_time_update_interval: 1000 }) },
modules: {
settings: { namespaced: true, getters: { GET_ADVANCED_PARTY_MODE: () => false } },
plexservers: { namespaced: true, getters: { GET_PLEX_SERVER: () => () => ({ name: 'Library' }) } },
synclounge: {
namespaced: true,
state: {
host: 'me',
users: {
me: participant('Host viewer'),
friend: participant('Morgan with a long display name', friendOptions),
buffering: participant('Sam', { state: 'buffering' }),
different: participant('Taylor', { media: { ...media, title: 'Another movie', ratingKey: '2' } }),
stopped: participant('Alex', { state: 'stopped', media: null }),
},
},
getters: {
GET_USERS: (state) => state.users,
GET_HOST_ID: (state) => state.host,
GET_HOST_USER: (state) => state.users[state.host],
GET_SOCKET_ID: () => 'me',
AM_I_HOST: (state) => state.host === 'me',
GET_ADJUSTED_HOST_TIME: () => () => 10000,
},
actions: { KICK_USER: (_, id) => kick(id), TRANSFER_HOST: (_, id) => transfer(id) },
},
},
});
const wrapper = mount(UserList, {
attachTo: document.body, global: { plugins: [store, createVuetify({ components, directives })] },
});
return {
wrapper, store, kick, transfer,
};
};
const body = () => new DOMWrapper(document.body);
const clickText = async (text) => {
const item = body().findAll('.v-list-item, button').find((element) => element.text() === text);
await item.trigger('click');
await flushPromises();
};

describe('participant actions and readable status', () => {
it('shows basic readiness and the Host badge without advanced controls', () => {
const { wrapper } = setup();
try {
expect(wrapper.text()).toContain('Host');
expect(wrapper.text()).toContain('In sync');
expect(wrapper.text()).toContain('Buffering');
expect(wrapper.text()).toContain('Different movie or episode');
expect(wrapper.text()).toContain('Not watching');
expect(wrapper.find('[aria-label="Remove Morgan with a long display name from the room"]').exists()).toBe(false);
} finally {
wrapper.unmount();
}
});
it.each([[3000, 'In sync'], [500, 'Catching up']])('renders readiness with tolerance %s', (tolerance, label) => {
const { wrapper } = setup({ time: 8000, syncFlexibility: tolerance });
try {
const row = wrapper.findAll('.v-list-item').find((item) => item.text().includes('Morgan'));
expect(row.text()).toContain(label);
} finally {
wrapper.unmount();
}
});
const moderationCases = [['Remove from room', 'kick'], ['Make host', 'transfer']];
it.each(moderationCases)('requires named confirmation for %s', async (label, action) => {
const { wrapper, kick, transfer } = setup();
try {
await wrapper.get('[aria-label="Actions for Morgan with a long display name"]').trigger('click');
await flushPromises();
await clickText(label);
expect(kick).not.toHaveBeenCalled();
expect(transfer).not.toHaveBeenCalled();
expect(body().findAll('.v-dialog')).toHaveLength(1);
expect(body().get('.v-dialog').text()).toContain('Morgan with a long display name');
await body().findAll('.v-dialog button').find((button) => button.text() === label).trigger('click');
await flushPromises();
expect(action === 'kick' ? kick : transfer).toHaveBeenCalledExactlyOnceWith('friend');
expect(action === 'kick' ? transfer : kick).not.toHaveBeenCalled();
} finally { wrapper.unmount(); }
});
it('cancels safely and refuses a stale confirmation after host permissions change', async () => {
const { wrapper, store, kick } = setup();
try {
wrapper.vm.requestModeration('friend', 'remove');
await flushPromises();
await body().findAll('.v-dialog button').find((button) => button.text() === 'Cancel').trigger('click');
expect(kick).not.toHaveBeenCalled();
expect(wrapper.vm.moderation).toBe(null);
wrapper.vm.requestModeration('friend', 'remove');
store.state.synclounge.host = 'friend';
await wrapper.vm.confirmModeration();
expect(kick).not.toHaveBeenCalled();
} finally { wrapper.unmount(); }
});
});
2 changes: 2 additions & 0 deletions src/__tests__/roomjoin-stale-url.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ describe('cancelled room creation', () => {
['advanced creation', AdvancedRoomJoin.methods.connect],
])('%s does not tear down a replacement join', async (label, action) => {
const ctx = {
GET_CONFIG: { servers: [{ url: 'https://server.invalid' }] },
GET_SERVER_HEALTH: () => ({ latency: 0 }),
$store: { commit: vi.fn() },
SET_AND_CONNECT_AND_JOIN_ROOM: vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError')),
DISCONNECT_IF_CONNECTED: vi.fn(),
Expand Down
12 changes: 10 additions & 2 deletions src/__tests__/sidebar-room-actions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vi.mock('@/components/ThePwaInstall.vue', () => ({ default: { template: '<div />
vi.mock('@/components/ReportProblem.vue', () => ({ default: { template: '<div />' } }));
vi.mock('@/components/TheSettingsDialog.vue', () => ({ default: { template: '<div />' } }));

const setup = (component, inRoom = true) => {
const setup = (component, inRoom = true, host = null) => {
const disconnect = vi.fn();
const store = createStore({
state: { isLeftSidebarOpen: true, isRightSidebarOpen: true },
Expand All @@ -31,7 +31,7 @@ const setup = (component, inRoom = true) => {
getters: {
IS_IN_ROOM: () => inRoom,
GET_USERS: () => ({ me: { username: 'Me' } }),
GET_HOST_USER: () => null,
GET_HOST_USER: () => host,
AM_I_HOST: () => false,
IS_PARTY_PAUSING_ENABLED: () => false,
IS_AUTO_HOST_ENABLED: () => false,
Expand Down Expand Up @@ -79,6 +79,14 @@ describe('sidebar close and room exit', () => {
} finally { wrapper.unmount(); }
});

it('omits host-only control rows for a viewer during playback', () => {
const { wrapper } = setup(TheSidebarRight, true, { username: 'Host', state: 'playing' });
try {
expect(wrapper.findAll('.switch-item')).toHaveLength(0);
expect(wrapper.get('[aria-label="Pause party"]').exists()).toBe(true);
} finally { wrapper.unmount(); }
});

it('disconnects only through the labeled navigation action', async () => {
const { wrapper, store, disconnect } = setup(TheSidebarLeft);
try {
Expand Down
Loading
Loading