fix(epg): preserve UTF-8 characters across XMLTV stream chunks (#1946)

* fix(epg): preserve UTF-8 across XMLTV stream chunks

* test(sources): wait for nested menu detachment before Escape
This commit is contained in:
4gray authored and GitHub committed 2026-10-15 20:06:19 +02:00
1 parent 9a94570a6a
commit 34470ea273
6 files changed
+170 -15

No files matched your search

+18 -10
View File
@@ -105,17 +105,25 @@ test.describe('Electron EPG', () => {
['double', true],
['single', false],
] as const) {
test(`@epg @electron imports ${name} gzip with a .gz URL and HTTP gzip`, async ({
test(`@epg @electron imports ${name} gzip with UTF-8 across decompression chunks`, async ({
dataDir,
}) => {
const server = await createGzipEpgServer(
createCurrentXmltvFixture(
'gzip-news',
'Gzip News',
'Gzip Bulletin'
),
doubleGzip
const title = 'Été 日本 🎬 Новости';
const xml = createCurrentXmltvFixture(
'gzip-news',
'Gzip Новости 東京 📺',
title
);
// Gunzip emits 16 KiB chunks. Put the first byte of É at the end
// of one chunk so the real parser worker must carry UTF-8 state.
const titleOffset = Buffer.byteLength(
xml.slice(0, xml.indexOf(title))
);
const padding = ' '.repeat(16 * 1024 - 1 - titleOffset - 7);
const paddedXml = xml.replace('<tv>', `<tv><!--${padding}-->`);
expect(Buffer.from(paddedXml)[16 * 1024 - 1]).toBe(0xc3);
expect(Buffer.from(paddedXml)[16 * 1024]).toBe(0x89);
const server = await createGzipEpgServer(paddedXml, doubleGzip);
try {
const app = await launchElectronApp(dataDir);
try {
@@ -129,13 +137,13 @@ test.describe('Electron EPG', () => {
window.electron.getEpgChannelMetadata(['gzip-news'])
);
expect(metadata['gzip-news']).toMatchObject({
displayName: 'Gzip News',
displayName: 'Gzip Новости 東京 📺',
});
const programs = await app.mainWindow.evaluate(() =>
window.electron.getChannelPrograms('gzip-news')
);
expect(programs.map((program) => program.title)).toEqual([
'Gzip Bulletin',
title,
]);
} finally {
await closeElectronApp(app);
+11 -2
View File
@@ -786,15 +786,24 @@ https://streams.example.test/original-url.m3u8
await dropM3uPlaylistOntoWorkspace(app.mainWindow, localFilePath);
await waitForM3uCatalog(app.mainWindow);
// Refresh for the active source lives in its switcher row menu.
await app.mainWindow.getByTestId('playlist-switcher-trigger').click();
await app.mainWindow
.getByTestId('playlist-switcher-trigger')
.click();
const currentRow = app.mainWindow.locator(
'app-playlist-switcher-panel [role="option"][aria-current="true"]'
);
await currentRow.locator('.source-row__more').click();
await expect(
app.mainWindow.getByRole('menuitem', { name: 'Refresh playlist' })
app.mainWindow.getByRole('menuitem', {
name: 'Refresh playlist',
})
).toBeVisible();
await app.mainWindow.keyboard.press('Escape');
// Material retains the nested menu overlay during its exit animation.
// Wait for detachment before sending Escape to the switcher beneath it.
await expect(
app.mainWindow.getByRole('menu', { includeHidden: true })
).toHaveCount(0);
await app.mainWindow.keyboard.press('Escape');
await expect(
app.mainWindow.locator('app-playlist-switcher-panel')