Skip to content

Commit 2d58128

Browse files
committed
feat: virtual list polishing and CI
1 parent 3b61621 commit 2d58128

File tree

7 files changed

+375
-11
lines changed

7 files changed

+375
-11
lines changed

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: ["main", "test"]
6+
pull_request:
7+
branches: ["main", "test"]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- name: Checkout repository
14+
uses: actions/checkout@v4
15+
16+
- name: Setup Node.js
17+
uses: actions/setup-node@v4
18+
with:
19+
node-version: '20'
20+
cache: npm
21+
22+
- name: Install dependencies
23+
run: npm ci
24+
25+
- name: Lint
26+
run: npm run lint
27+
28+
- name: Verify main-process modules
29+
run: node scripts/verify-main-modules.js
30+
31+
- name: Verify renderer queue logic
32+
run: node scripts/verify-renderer-queue.js

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ <h2 class="panel-heading-sm centered" data-i18n="advanced.title">Mode avancé</h
133133
<li><code>am translate</code><span data-i18n="advanced.cmd.translate">gérer traductions</span></li>
134134
</ul>
135135

136-
<p class="note"><em><span data-i18n="advanced.note.appman">Pour la version sans privilèges, remplacez <code>am</code> par <code>appman</code>.</span></em></p>
136+
<p class="note"><em><span data-i18n-html="advanced.note.appman">Pour la version sans privilèges, remplacez <code>am</code> par <code>appman</code>.</span></em></p>
137137

138138
<p class="docs-link"><a href="https://github.com/ivan-hc/AM" target="_blank" rel="noopener"><span data-i18n="advanced.docs">Documentation complète & guides →</span> AM (GitHub)</a></p>
139139
</section>

scripts/verify-main-modules.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ const fs = require('fs');
22
const os = require('os');
33
const path = require('path');
44
const childProcess = require('child_process');
5+
const undici = require('undici');
56

67
const originalExec = childProcess.exec;
8+
const originalFetch = undici.fetch;
79
let tempDir = null;
810
let success = false;
11+
let categoriesBackup = null;
912

1013
(async () => {
1114
let execInvocations = 0;
@@ -30,7 +33,54 @@ let success = false;
3033
});
3134
};
3235

36+
const repoRoot = path.resolve(__dirname, '..');
37+
const categoriesCachePath = path.join(repoRoot, 'categories-cache.json');
38+
const categoriesMetaPath = path.join(repoRoot, 'categories-cache.meta.json');
39+
40+
function snapshotCategoriesFiles() {
41+
return {
42+
cache: fs.existsSync(categoriesCachePath) ? fs.readFileSync(categoriesCachePath) : null,
43+
meta: fs.existsSync(categoriesMetaPath) ? fs.readFileSync(categoriesMetaPath) : null
44+
};
45+
}
46+
47+
function restoreCategoriesFiles(snapshot) {
48+
if (!snapshot) return;
49+
if (snapshot.cache) fs.writeFileSync(categoriesCachePath, snapshot.cache);
50+
else if (fs.existsSync(categoriesCachePath)) fs.rmSync(categoriesCachePath);
51+
if (snapshot.meta) fs.writeFileSync(categoriesMetaPath, snapshot.meta);
52+
else if (fs.existsSync(categoriesMetaPath)) fs.rmSync(categoriesMetaPath);
53+
}
54+
55+
function createHeadersProxy(headers = {}) {
56+
const normalized = Object.fromEntries(
57+
Object.entries(headers).map(([k, v]) => [String(k).toLowerCase(), v])
58+
);
59+
return {
60+
get(name) {
61+
return normalized[String(name).toLowerCase()] || null;
62+
}
63+
};
64+
}
65+
66+
function createResponse({ status = 200, ok = true, jsonData = null, textData = '', headers = {} }) {
67+
return {
68+
status,
69+
ok,
70+
async json() {
71+
if (jsonData === null) throw new Error('JSON payload missing');
72+
return jsonData;
73+
},
74+
async text() {
75+
return textData;
76+
},
77+
headers: createHeadersProxy(headers)
78+
};
79+
}
80+
3381
try {
82+
categoriesBackup = snapshotCategoriesFiles();
83+
3484
const { detectPackageManager, invalidatePackageManagerCache } = require('../src/main/packageManager');
3585
const { createIconCacheManager } = require('../src/main/iconCache');
3686

@@ -82,6 +132,71 @@ let success = false;
82132
if (!purgeResult || purgeResult.removed < 1) throw new Error('Purge cache did not report removed files');
83133
if (fs.existsSync(fakeIconPath)) throw new Error('Purge cache did not delete dummy icon');
84134

135+
const fileEtags = new Map();
136+
const markdownByFile = {
137+
'games.md': `| App | Desc |\n| --- | --- |\n| ***alpha*** | great app |`,
138+
'tools.md': `| App | Desc |\n| --- | --- |\n| ***beta*** | tool desc |`
139+
};
140+
141+
undici.fetch = async (url, options = {}) => {
142+
const headers = options.headers || {};
143+
if (url.endsWith('/contents')) {
144+
return createResponse({
145+
jsonData: [
146+
{ name: 'games.md' },
147+
{ name: 'tools.md' },
148+
{ name: 'README.md' }
149+
]
150+
});
151+
}
152+
const fileName = path.basename(url);
153+
if (!markdownByFile[fileName]) {
154+
return createResponse({ status: 404, ok: false, textData: 'missing' });
155+
}
156+
const previousEtag = fileEtags.get(fileName);
157+
if (headers['If-None-Match'] && previousEtag && headers['If-None-Match'] === previousEtag) {
158+
return createResponse({ status: 304, ok: false });
159+
}
160+
const nextEtag = `W/"etag-${fileName}-${Date.now()}"`;
161+
fileEtags.set(fileName, nextEtag);
162+
return createResponse({
163+
textData: markdownByFile[fileName],
164+
headers: { etag: nextEtag, 'last-modified': new Date().toUTCString() }
165+
});
166+
};
167+
168+
const { registerCategoryHandlers } = require('../src/main/categories');
169+
const ipcHandlers = new Map();
170+
const ipcMain = {
171+
handle(channel, handler) {
172+
ipcHandlers.set(channel, handler);
173+
}
174+
};
175+
176+
registerCategoryHandlers(ipcMain);
177+
if (!ipcHandlers.has('fetch-all-categories')) throw new Error('fetch-all-categories handler missing');
178+
179+
const firstFetch = await ipcHandlers.get('fetch-all-categories')();
180+
if (!firstFetch.ok) throw new Error(`fetch-all-categories failed: ${firstFetch.error}`);
181+
if (!Array.isArray(firstFetch.categories) || firstFetch.categories.length !== 2) {
182+
throw new Error('Unexpected categories payload on first fetch');
183+
}
184+
if (!firstFetch.categories[0].apps.includes('alpha')) throw new Error('Category parsing failed');
185+
186+
const cached = await ipcHandlers.get('get-categories-cache')();
187+
if (!cached.ok || cached.categories.length !== 2) throw new Error('get-categories-cache returned invalid data');
188+
189+
const secondFetch = await ipcHandlers.get('fetch-all-categories')();
190+
if (!secondFetch.ok) throw new Error('Second fetch should still be ok (even with 304)');
191+
if (secondFetch.categories.length !== firstFetch.categories.length) {
192+
throw new Error('Second fetch should reuse cached categories when not modified');
193+
}
194+
195+
const deleteResult = await ipcHandlers.get('delete-categories-cache')();
196+
if (!deleteResult.ok) throw new Error('delete-categories-cache failed');
197+
if (fs.existsSync(categoriesCachePath)) throw new Error('Cache file still present after deletion');
198+
if (fs.existsSync(categoriesMetaPath)) throw new Error('Cache meta file still present after deletion');
199+
85200
success = true;
86201
console.log('✔ Main-process modules verified successfully.');
87202
} catch (err) {
@@ -96,6 +211,8 @@ let success = false;
96211
console.warn('Warning: failed to clean temporary directory:', cleanupErr.message);
97212
}
98213
childProcess.exec = originalExec;
214+
undici.fetch = originalFetch;
215+
restoreCategoriesFiles(categoriesBackup);
99216
if (success) {
100217
process.exitCode = 0;
101218
}

scripts/verify-renderer-queue.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const vm = require('vm');
4+
5+
function extractQueueSnippet(fullSource) {
6+
const startMarker = 'let activeInstallSession = {';
7+
const endMarker = 'let syncBtn = null;';
8+
const start = fullSource.indexOf(startMarker);
9+
const end = fullSource.indexOf(endMarker);
10+
if (start === -1 || end === -1 || end <= start) {
11+
throw new Error('Unable to locate install queue section in renderer.js');
12+
}
13+
return fullSource.slice(start, end);
14+
}
15+
16+
function createDocumentStub() {
17+
const emptyArray = [];
18+
return {
19+
body: { classList: { toggle() {}, add() {}, remove() {} } },
20+
querySelectorAll() { return emptyArray; },
21+
querySelector() { return null; }
22+
};
23+
}
24+
25+
function createButtonStub(name) {
26+
const attrs = { 'data-name': name };
27+
return {
28+
hidden: false,
29+
disabled: false,
30+
textContent: '',
31+
classList: { add() {}, remove() {} },
32+
setAttribute(key, value) { attrs[key] = value; },
33+
getAttribute(key) { return attrs[key]; }
34+
};
35+
}
36+
37+
function assert(condition, message) {
38+
if (!condition) throw new Error(message);
39+
}
40+
41+
(async () => {
42+
const rendererPath = path.join(__dirname, '..', 'src', 'renderer', 'renderer.js');
43+
const rendererSource = fs.readFileSync(rendererPath, 'utf8');
44+
const queueSnippet = extractQueueSnippet(rendererSource);
45+
const instrumentedSnippet = `${queueSnippet}\n globalThis.__queueTest = { installQueue, enqueueInstall, removeFromQueue, processNextInstall, activeInstallSession };`;
46+
47+
const toastMessages = [];
48+
const queueIndicatorsUpdates = [];
49+
const startStreamingCalls = [];
50+
51+
const context = {
52+
console,
53+
setTimeout,
54+
clearTimeout,
55+
CSS: { escape: (value) => value },
56+
Node: { TEXT_NODE: 3 },
57+
window: {
58+
electronAPI: {
59+
amAction: () => Promise.resolve()
60+
}
61+
},
62+
document: createDocumentStub(),
63+
detailsInstallBtn: createButtonStub('alpha'),
64+
state: { viewMode: 'grid', installed: new Set(), filtered: [] },
65+
showToast(msg) { toastMessages.push(msg); },
66+
t(key, vars) {
67+
if (!vars) return key;
68+
return `${key}:${JSON.stringify(vars)}`;
69+
},
70+
startStreamingInstall(name) {
71+
startStreamingCalls.push(name);
72+
return Promise.resolve();
73+
},
74+
loadApps: () => Promise.resolve(),
75+
applySearch: () => {},
76+
refreshAllInstallButtons: () => {},
77+
refreshTileBadges: () => {},
78+
updateQueueIndicators: () => queueIndicatorsUpdates.push(Date.now())
79+
};
80+
81+
vm.createContext(context);
82+
vm.runInContext(instrumentedSnippet, context);
83+
84+
const { installQueue, enqueueInstall, removeFromQueue, processNextInstall, activeInstallSession } = context.__queueTest || {};
85+
86+
assert(Array.isArray(installQueue), 'installQueue array not exposed');
87+
88+
activeInstallSession.id = null;
89+
activeInstallSession.done = true;
90+
enqueueInstall('alpha');
91+
assert(startStreamingCalls.includes('alpha'), 'Immediate install should start streaming');
92+
assert(installQueue.length === 0, 'Queue should be empty after starting install');
93+
94+
activeInstallSession.id = 'session-1';
95+
activeInstallSession.name = 'alpha';
96+
activeInstallSession.done = false;
97+
enqueueInstall('beta');
98+
assert(installQueue.length === 1, 'Item should be queued while install active');
99+
100+
const removed = removeFromQueue('beta');
101+
assert(removed === true, 'removeFromQueue should return true when entry existed');
102+
assert(installQueue.length === 0, 'Queue should be empty after removal');
103+
104+
enqueueInstall('gamma');
105+
assert(installQueue.length === 1, 'Queue should contain gamma before processing');
106+
107+
activeInstallSession.id = null;
108+
activeInstallSession.done = true;
109+
processNextInstall();
110+
assert(startStreamingCalls.includes('gamma'), 'processNextInstall should start gamma streaming');
111+
assert(queueIndicatorsUpdates.length >= 1, 'Queue indicators should update when queue changes');
112+
113+
console.log('✔ Renderer queue logic verified successfully.');
114+
})().catch((err) => {
115+
console.error('✖ Renderer queue verification failed:', err);
116+
process.exitCode = 1;
117+
});

src/renderer/i18n/translations.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@
9494
'advanced.cmd.remove': 'supprimer',
9595
'advanced.cmd.extra': 'installer depuis GitHub',
9696
'advanced.cmd.translate': 'gérer traductions',
97-
'advanced.note.appman': 'Pour la version sans privilèges, remplacez am par appman',
97+
'advanced.note.appman': 'Pour la version sans privilèges, remplacez <code>am</code> par <code>appman</code>.',
9898
'advanced.docs': 'Documentation complète & guides →',
9999
'details.back': '← Retour',
100100
'details.desc': 'Description',
@@ -212,7 +212,7 @@
212212
'advanced.cmd.remove': 'remove',
213213
'advanced.cmd.extra': 'install from GitHub',
214214
'advanced.cmd.translate': 'manage translations',
215-
'advanced.note.appman': 'For the non-privileged version, replace am with appman',
215+
'advanced.note.appman': 'For the non-privileged version, replace <code>am</code> with <code>appman</code>.',
216216
'advanced.docs': 'Complete documentation & guides →',
217217
'details.back': '← Back',
218218
'details.desc': 'Description',
@@ -330,7 +330,7 @@
330330
'advanced.cmd.remove': 'rimuovi',
331331
'advanced.cmd.extra': 'installa da GitHub',
332332
'advanced.cmd.translate': 'gestisci traduzioni',
333-
'advanced.note.appman': 'Per la versione senza privilegi, sostituisci am con appman',
333+
'advanced.note.appman': 'Per la versione senza privilegi, sostituisci <code>am</code> con <code>appman</code>.',
334334
'advanced.docs': 'Documentazione completa & guide →',
335335
'details.back': '← Indietro',
336336
'details.desc': 'Descrizione',

src/renderer/renderer.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,16 @@ function applyTranslations() {
861861
}
862862
}
863863
});
864+
// data-i18n-html (innerHTML autorisé pour cas spécifiques)
865+
document.querySelectorAll('[data-i18n-html]').forEach(el => {
866+
const key = el.getAttribute('data-i18n-html');
867+
const localized = (translations[lang] && translations[lang][key])
868+
|| (translations['en'] && translations['en'][key])
869+
|| (translations['fr'] && translations['fr'][key]);
870+
if (localized) {
871+
el.innerHTML = localized;
872+
}
873+
});
864874
// data-i18n-placeholder
865875
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
866876
const key = el.getAttribute('data-i18n-placeholder');
@@ -1254,6 +1264,9 @@ function showDetails(appName) {
12541264
if (tabsRowSecondary) tabsRowSecondary.style.visibility = 'hidden';
12551265
// Suppression de la barre : rien à faire
12561266
document.body.classList.add('details-mode');
1267+
if (virtualListApi?.disconnectObservers) {
1268+
try { virtualListApi.disconnectObservers(); } catch (_) {}
1269+
}
12571270
if (appsDiv) appsDiv.hidden = true;
12581271
loadRemoteDescription(app.name).catch(err => {
12591272
if (detailsLong) detailsLong.textContent = t('details.errorDesc', {error: err?.message || err || t('error.unknown')});
@@ -1264,6 +1277,9 @@ function exitDetailsView() {
12641277
if (appDetailsSection) appDetailsSection.hidden = true;
12651278
document.body.classList.remove('details-mode');
12661279
if (appsDiv) appsDiv.hidden = false;
1280+
if (virtualListApi?.renderVirtualList) {
1281+
try { virtualListApi.renderVirtualList(); } catch (_) {}
1282+
}
12671283
// Réaffiche la barre d'onglets catégories et le bouton miroir/tout
12681284
const tabsRowSecondary = document.querySelector('.tabs-row-secondary');
12691285
if (tabsRowSecondary) tabsRowSecondary.style.visibility = 'visible';
@@ -1543,6 +1559,15 @@ tabs.forEach(tab => {
15431559
const isAdvancedTab = state.activeCategory === 'advanced';
15441560
if (updatesPanel) updatesPanel.hidden = !isUpdatesTab;
15451561
if (advancedPanel) advancedPanel.hidden = !isAdvancedTab;
1562+
const showingApps = !(isUpdatesTab || isAdvancedTab);
1563+
if (appsDiv) appsDiv.hidden = !showingApps;
1564+
if (showingApps) {
1565+
if (virtualListApi?.renderVirtualList) {
1566+
try { virtualListApi.renderVirtualList(); } catch (_) {}
1567+
}
1568+
} else if (virtualListApi?.disconnectObservers) {
1569+
try { virtualListApi.disconnectObservers(); } catch (_) {}
1570+
}
15461571
if (!isUpdatesTab && updateSpinner) updateSpinner.hidden = true;
15471572
if (isUpdatesTab) {
15481573
if (updateInProgress) {

0 commit comments

Comments
 (0)