Expand integration coverage and fix settings UI

This commit is contained in:
ajp_anton
2026-07-21 00:42:44 +00:00
parent 25088ce91d
commit 094d5ad9f0
26 changed files with 1665 additions and 66 deletions
+218
View File
@@ -0,0 +1,218 @@
const fs = require('node:fs');
const { expect, test } = require('@playwright/test');
const fixturePath = process.env.MULTILANG_BROWSER_FIXTURE;
if (!fixturePath) {
throw new Error('MULTILANG_BROWSER_FIXTURE must point to the temporary JF12 fixture description.');
}
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const actionFields = ['title', 'overview', 'tagline', 'poster', 'logo', 'banner', 'thumb', 'backdrop'];
function authHeaders() {
return { Authorization: `MediaBrowser Token="${fixture.token}"` };
}
function jellyfinActions() {
return Object.fromEntries(actionFields.map(field => [field, ['Jellyfin']]));
}
async function apiGet(request, path) {
const response = await request.get(path, { headers: authHeaders() });
expect(response.ok()).toBeTruthy();
return response.json();
}
async function apiPost(request, path, data) {
const response = await request.post(path, {
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
data
});
expect(response.ok()).toBeTruthy();
return response.json();
}
async function restoreFixtureRules(request) {
const config = await apiGet(request, '/Multilang/AdminConfig');
Object.assign(config, {
Languages: ['en', 'fi'],
ItemsProxyCacheThresholdMs: 0,
ItemsProxyCacheTtlMinutes: 5,
ItemsProxyCacheMaxMiB: 8,
PrecacheMovieLibraries: true,
PrecacheTvShowLibraries: false
});
await apiPost(request, '/Multilang/AdminConfig', config);
const rules = await apiGet(request, '/Multilang/UserRules/self');
Object.assign(rules, {
Enabled: true,
SortLocale: 'fi-FI',
Categories: [],
FallbackFieldActions: {
...jellyfinActions(),
title: ['Language:fi', 'Jellyfin'],
overview: ['Language:fi', 'Jellyfin'],
tagline: ['Language:fi', 'Jellyfin']
}
});
await apiPost(request, '/Multilang/UserRules/self', rules);
await apiPost(request, '/Multilang/ClearCache', {});
}
async function signIn(page) {
await page.goto('/web/index.html');
await page.getByRole('textbox', { name: 'User' }).fill(fixture.username);
await page.getByRole('textbox', { name: 'Password' }).fill(fixture.password);
await page.getByRole('button', { name: /sign in/i }).click();
await expect(page).not.toHaveURL(/login/i);
}
async function openFixtureLibrary(page, expectedTitle) {
await page.goto(`/web/#/movies?topParentId=${fixture.libraryId}&collectionType=movies`);
await expect(page.getByText(expectedTitle, { exact: true })).toBeVisible();
}
async function getFixtureItems(request) {
const upstream = `/Users/${fixture.userId}/Items?ParentId=${fixture.libraryId}&IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&SortOrder=Ascending&Fields=Overview,Genres,GenreItems`;
const response = await request.get('/Multilang/ItemsProxy', {
headers: { Authorization: `MediaBrowser Token="${fixture.token}"` },
params: { url: upstream, mlLocale: 'fi-FI' }
});
expect(response.ok()).toBeTruthy();
return response.json();
}
test.beforeEach(async ({ request }) => {
await restoreFixtureRules(request);
});
test('pre-caches the fixture movie library after user activity', async ({ request }) => {
const trigger = await request.get('/Multilang/ItemsProxy', {
headers: authHeaders(),
params: {
url: `/Users/${fixture.userId}/Items/Latest?Limit=1`,
mlLocale: 'fi-FI'
}
});
expect(trigger.ok()).toBeTruthy();
await expect.poll(async () => {
const entries = await apiGet(request, '/Multilang/CacheEntries');
return entries.some(entry => entry.Url.includes(`ParentId=${fixture.libraryId}`));
}, { timeout: 60000 }).toBeTruthy();
});
test('shows configured translations and uses the injected ItemsProxy route', async ({ page, request }) => {
const proxiedRequests = [];
page.on('request', request => {
if (new URL(request.url()).pathname === '/Multilang/ItemsProxy') {
proxiedRequests.push(request.url());
}
});
await signIn(page);
await openFixtureLibrary(page, fixture.translatedTitles[0]);
for (const title of fixture.translatedTitles) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
}
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Id)).toEqual(fixture.translatedOrderedItemIds);
expect(payload.Items.map(item => item.Name)).toEqual(fixture.translatedSortedTitles);
expect(payload.Items.find(item => item.Name === 'The Clock').Overview).toBe('English overview for The Clock.');
expect(payload.Items.find(item => item.Name === 'Ankkuri').Genres).toEqual(['Draama']);
expect(payload.Items.find(item => item.Name === 'Majakka').Genres).toEqual(['Komedia']);
await expect.poll(() => proxiedRequests.length).toBeGreaterThan(0);
});
test('returns to Jellyfin titles when Multilang is disabled for the user', async ({ page, request }) => {
const rules = await apiGet(request, '/Multilang/UserRules/self');
rules.Enabled = false;
await apiPost(request, '/Multilang/UserRules/self', rules);
await signIn(page);
await openFixtureLibrary(page, fixture.jellyfinTitles[0]);
for (const title of fixture.jellyfinTitles) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
}
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Id)).toEqual(fixture.orderedItemIds);
expect(payload.Items.map(item => item.Name)).toEqual(fixture.jellyfinTitles);
expect(payload.Items.find(item => item.Name === 'The Anchor').Genres).toEqual(['Drama']);
expect(payload.Items.find(item => item.Name === 'The Beacon').Genres).toEqual(['Comedy']);
});
test('applies a structured category rule before fallback translation actions', async ({ request }) => {
const rules = await apiGet(request, '/Multilang/UserRules/self');
const categoryActions = jellyfinActions();
categoryActions.title = ['Jellyfin'];
rules.Categories = [{
Id: 'browser-fixture-swedish',
Label: 'Swedish originals',
Requirements: [{
Field: 'original_language',
Fields: ['original_language'],
Relation: 'is',
Values: ['sv'],
UseOr: false,
UseFieldOr: false
}],
MatchAllConditions: true,
Scopes: ['M'],
FieldActionLists: categoryActions,
FieldActions: {}
}];
await apiPost(request, '/Multilang/UserRules/self', rules);
const payload = await getFixtureItems(request);
expect(payload.Items.map(item => item.Name)).toEqual(['Ankkuri', 'The Beacon', 'The Clock']);
});
test('saves admin configuration through the dashboard page', async ({ page, request }) => {
await signIn(page);
await page.goto('/web/#/configurationpage?name=Multilang');
await expect(page.locator('#ml-admin')).toBeVisible();
await page.locator('#ml-languages').fill('en, fi, sv');
await page.locator('#ml-precache-movies').check();
await page.locator('#ml-precache-tvshows').uncheck();
await page.locator('#ml-save').click();
await expect(page.locator('#ml-status')).toHaveText('Saved.');
const config = await apiGet(request, '/Multilang/AdminConfig');
expect(config.Languages).toEqual(['en', 'fi', 'sv']);
expect(config.PrecacheMovieLibraries).toBeTruthy();
expect(config.PrecacheTvShowLibraries).toBeFalsy();
});
test('saves a structured classification rule through the user settings page', async ({ page, request }) => {
await signIn(page);
await page.goto(`/web/#/mypreferences?userId=${fixture.userId}`);
await page.getByRole('button', { name: 'User Menu' }).click();
await expect(page.locator('#mlUserRulesMenuItem')).toBeVisible();
await page.locator('#mlUserRulesMenuItem').click();
await expect(page.locator('#ml-user')).toBeVisible();
await expect(page.locator('#ml-sort-locale option[value="en"]')).toHaveCount(1);
await page.locator('#ml-sort-locale').selectOption('en');
await page.getByRole('button', { name: 'Add classification rule' }).click();
const rule = page.locator('.ml-classification-rule').last();
await rule.locator('[data-category-label]').fill('Browser fixture rule');
await rule.locator('.ml-field-dropdown button').click();
await rule.locator('.ml-field-dropdown input[value="original_language"]').check();
await rule.locator('select.ml-relation-select').selectOption('is');
await rule.locator('.ml-value-dropdown').nth(1).locator('button').click();
await rule.locator('.ml-value-dropdown').nth(1).locator('input[value="sv"]').check();
await page.locator('#ml-save-user').click();
await expect(page.locator('#ml-user-status')).toHaveText('Saved.');
const rules = await apiGet(request, '/Multilang/UserRules/self');
expect(rules.SortLocale).toBe('en');
expect(rules.Categories).toHaveLength(1);
expect(rules.Categories[0].Label).toBe('Browser fixture rule');
expect(rules.Categories[0].Requirements).toEqual([expect.objectContaining({
Fields: ['original_language'],
Relation: 'is',
Values: ['sv']
})]);
});