fix: harden import preflight admission
Keep configured size-limit counts identical to real queue admission, invalidate cancelled import generations, preserve explicit empty destination choices, normalize Windows namespace aliases, inspect files through one read handle, and retain exact rejection balances.
This commit is contained in:
+52
-11
@@ -11,8 +11,15 @@
|
|||||||
const { applyFilenameFilter } = filenameFilter;
|
const { applyFilenameFilter } = filenameFilter;
|
||||||
|
|
||||||
function normalizePathValue(value) {
|
function normalizePathValue(value) {
|
||||||
const text = String(value ?? '').trim();
|
let text = String(value ?? '').trim();
|
||||||
return text ? path.normalize(text) : '';
|
if (!text) return '';
|
||||||
|
const uncNamespace = text.match(/^[\\/]{2}\?[\\/]UNC[\\/]/i);
|
||||||
|
if (uncNamespace) text = `\\\\${text.slice(uncNamespace[0].length)}`;
|
||||||
|
else {
|
||||||
|
const driveNamespace = text.match(/^[\\/]{2}\?[\\/](?=[A-Za-z]:[\\/])/);
|
||||||
|
if (driveNamespace) text = text.slice(driveNamespace[0].length);
|
||||||
|
}
|
||||||
|
return path.normalize(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeEntry(value) {
|
function normalizeEntry(value) {
|
||||||
@@ -52,6 +59,25 @@ function unavailableReason(result) {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function inspectReadableImportPath(filePath, openPath) {
|
||||||
|
let fileHandle = null;
|
||||||
|
try {
|
||||||
|
fileHandle = await openPath(filePath, 'r');
|
||||||
|
const fileStat = await fileHandle.stat();
|
||||||
|
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
|
||||||
|
return { exists: true, readable: true, size: fileStat.size };
|
||||||
|
} catch (error) {
|
||||||
|
if (error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) return { exists: false };
|
||||||
|
return { exists: true, readable: false };
|
||||||
|
} finally {
|
||||||
|
if (fileHandle) {
|
||||||
|
try {
|
||||||
|
await fileHandle.close();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function inspectImportEntries(entries, options = {}) {
|
async function inspectImportEntries(entries, options = {}) {
|
||||||
const input = Array.isArray(entries) ? entries : [];
|
const input = Array.isArray(entries) ? entries : [];
|
||||||
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
|
const caseInsensitive = options.caseInsensitive ?? (typeof process === 'object' ? process.platform === 'win32' : true);
|
||||||
@@ -111,20 +137,32 @@ async function inspectImportEntries(entries, options = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSelectedHosters(values) {
|
||||||
|
return Array.from(new Set((Array.isArray(values) ? values : [])
|
||||||
|
.map(value => String(value ?? '').trim())
|
||||||
|
.filter(Boolean)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImportPairEligible(file, hoster, hosterSettings = {}) {
|
||||||
|
const maxSizeMb = Number(hosterSettings?.[hoster]?.maxSizeMb);
|
||||||
|
return !(maxSizeMb > 0 && Number(file?.size) > maxSizeMb * 1024 * 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEligibleImportHosters(file, selectedHosters, hosterSettings = {}) {
|
||||||
|
return normalizeSelectedHosters(selectedHosters)
|
||||||
|
.filter(hoster => isImportPairEligible(file, hoster, hosterSettings));
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeImportPlan(input = {}) {
|
function summarizeImportPlan(input = {}) {
|
||||||
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
|
const inspection = input.inspection && typeof input.inspection === 'object' ? input.inspection : {};
|
||||||
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
|
const accepted = Array.isArray(inspection.accepted) ? inspection.accepted : [];
|
||||||
const selectedHosters = Array.from(new Set((Array.isArray(input.selectedHosters) ? input.selectedHosters : [])
|
const selectedHosters = normalizeSelectedHosters(input.selectedHosters);
|
||||||
.map(value => String(value ?? '').trim())
|
|
||||||
.filter(Boolean)));
|
|
||||||
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
|
const settings = input.hosterSettings && typeof input.hosterSettings === 'object' ? input.hosterSettings : {};
|
||||||
let sizeLimitedJobCount = 0;
|
let jobCount = 0;
|
||||||
for (const file of accepted) {
|
for (const file of accepted) {
|
||||||
for (const hoster of selectedHosters) {
|
jobCount += getEligibleImportHosters(file, selectedHosters, settings).length;
|
||||||
const maxSizeMb = Number(settings[hoster]?.maxSizeMb);
|
|
||||||
if (maxSizeMb > 0 && Number(file.size) > maxSizeMb * 1024 * 1024) sizeLimitedJobCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const sizeLimitedJobCount = accepted.length * selectedHosters.length - jobCount;
|
||||||
return {
|
return {
|
||||||
candidateCount: Number(inspection.candidateCount) || 0,
|
candidateCount: Number(inspection.candidateCount) || 0,
|
||||||
duplicateCount: Number(inspection.duplicateCount) || 0,
|
duplicateCount: Number(inspection.duplicateCount) || 0,
|
||||||
@@ -132,13 +170,16 @@ function summarizeImportPlan(input = {}) {
|
|||||||
unavailableCount: Number(inspection.unavailableCount) || 0,
|
unavailableCount: Number(inspection.unavailableCount) || 0,
|
||||||
acceptedCount: accepted.length,
|
acceptedCount: accepted.length,
|
||||||
targetCount: selectedHosters.length,
|
targetCount: selectedHosters.length,
|
||||||
jobCount: accepted.length * selectedHosters.length - sizeLimitedJobCount,
|
jobCount,
|
||||||
sizeLimitedJobCount
|
sizeLimitedJobCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getEligibleImportHosters,
|
||||||
inspectImportEntries,
|
inspectImportEntries,
|
||||||
|
inspectReadableImportPath,
|
||||||
|
isImportPairEligible,
|
||||||
summarizeImportPlan
|
summarizeImportPlan
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('./lib/u
|
|||||||
const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
|
const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
|
||||||
const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
|
const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
|
||||||
const { createUploadStartReservation } = require('./lib/upload-start-reservation');
|
const { createUploadStartReservation } = require('./lib/upload-start-reservation');
|
||||||
const { inspectImportEntries } = require('./lib/import-preflight');
|
const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight');
|
||||||
|
|
||||||
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||||
_eventLoopDelay.enable();
|
_eventLoopDelay.enable();
|
||||||
@@ -2255,22 +2255,7 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => {
|
|||||||
existingPaths: input.existingPaths,
|
existingPaths: input.existingPaths,
|
||||||
filenameFilter: currentConfig.globalSettings?.filenameFilter,
|
filenameFilter: currentConfig.globalSettings?.filenameFilter,
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
inspectPath: async filePath => {
|
inspectPath: filePath => inspectReadableImportPath(filePath, fs.promises.open)
|
||||||
let fileStat;
|
|
||||||
try {
|
|
||||||
fileStat = await fs.promises.stat(filePath);
|
|
||||||
} catch (error) {
|
|
||||||
if (error && error.code === 'ENOENT') return { exists: false };
|
|
||||||
return { exists: true, readable: false };
|
|
||||||
}
|
|
||||||
if (!fileStat.isFile()) return { exists: true, readable: false, size: fileStat.size };
|
|
||||||
try {
|
|
||||||
await fs.promises.access(filePath, fs.constants.R_OK);
|
|
||||||
} catch {
|
|
||||||
return { exists: true, readable: false, size: fileStat.size };
|
|
||||||
}
|
|
||||||
return { exists: true, readable: true, size: fileStat.size };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+59
-18
@@ -1285,14 +1285,17 @@ function closeHosterModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyHosterSelection() {
|
async function applyHosterSelection() {
|
||||||
|
if (_pendingImportInspections > 0) return false;
|
||||||
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
|
selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked'))
|
||||||
.map(input => input.dataset.hosterModal);
|
.map(input => input.dataset.hosterModal);
|
||||||
// Move pending files to selectedFiles on confirm
|
// Move pending files to selectedFiles on confirm
|
||||||
const pendingPaths = new Set(_pendingFiles.map(f => f.path));
|
const admittedFiles = _pendingFiles.filter(file => window.ImportPreflight
|
||||||
if (_pendingFiles.length > 0) {
|
.getEligibleImportHosters(file, selectedUploadHosters, hosterSettings).length > 0);
|
||||||
selectedFiles.push(..._pendingFiles);
|
const pendingPaths = new Set(admittedFiles.map(f => f.path));
|
||||||
_pendingFiles = [];
|
if (admittedFiles.length > 0) {
|
||||||
|
selectedFiles.push(...admittedFiles);
|
||||||
}
|
}
|
||||||
|
_pendingFiles = [];
|
||||||
clearDedupKeysForPaths(pendingPaths);
|
clearDedupKeysForPaths(pendingPaths);
|
||||||
renderHosterSummary();
|
renderHosterSummary();
|
||||||
|
|
||||||
@@ -1314,8 +1317,12 @@ async function applyHosterSelection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cancelHosterModal() {
|
function cancelHosterModal() {
|
||||||
|
_importGeneration++;
|
||||||
|
_pendingImportInspections = 0;
|
||||||
|
_importCoordination = Promise.resolve();
|
||||||
_pendingFiles = [];
|
_pendingFiles = [];
|
||||||
_pendingImportInspection = null;
|
_pendingImportInspection = null;
|
||||||
|
syncImportConfirmationState();
|
||||||
closeHosterModal();
|
closeHosterModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1554,6 +1561,8 @@ function setupDragDrop() {
|
|||||||
let _pendingFiles = []; // Files waiting for hoster modal confirmation
|
let _pendingFiles = []; // Files waiting for hoster modal confirmation
|
||||||
let _pendingImportInspection = null;
|
let _pendingImportInspection = null;
|
||||||
let _importCoordination = Promise.resolve();
|
let _importCoordination = Promise.resolve();
|
||||||
|
let _importGeneration = 0;
|
||||||
|
let _pendingImportInspections = 0;
|
||||||
|
|
||||||
let _addingDropped = false;
|
let _addingDropped = false;
|
||||||
|
|
||||||
@@ -1595,13 +1604,24 @@ function getImportPlanHosters() {
|
|||||||
: getSelectedHosters();
|
: getSelectedHosters();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImportPlanSummary() {
|
function getImportPlanSummary() {
|
||||||
if (!_pendingImportInspection || !window.ImportPreflight) return;
|
if (!_pendingImportInspection || !window.ImportPreflight) return null;
|
||||||
const summary = window.ImportPreflight.summarizeImportPlan({
|
return window.ImportPreflight.summarizeImportPlan({
|
||||||
inspection: _pendingImportInspection,
|
inspection: _pendingImportInspection,
|
||||||
selectedHosters: getImportPlanHosters(),
|
selectedHosters: getImportPlanHosters(),
|
||||||
hosterSettings
|
hosterSettings
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncImportConfirmationState() {
|
||||||
|
const confirmButton = document.getElementById('confirmHosterModalBtn');
|
||||||
|
if (confirmButton) confirmButton.disabled = _pendingImportInspections > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImportPlanSummary() {
|
||||||
|
const summary = getImportPlanSummary();
|
||||||
|
syncImportConfirmationState();
|
||||||
|
if (!summary) return;
|
||||||
const values = {
|
const values = {
|
||||||
importPlanCandidates: summary.candidateCount,
|
importPlanCandidates: summary.candidateCount,
|
||||||
importPlanDuplicates: summary.duplicateCount,
|
importPlanDuplicates: summary.duplicateCount,
|
||||||
@@ -1618,21 +1638,41 @@ function renderImportPlanSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRejectedImportBalance(inspection) {
|
||||||
|
const values = [
|
||||||
|
['Kandidaten', inspection.candidateCount],
|
||||||
|
['Bereits vorhanden / dupliziert', inspection.duplicateCount],
|
||||||
|
['Durch Dateinamenfilter ausgeschlossen', inspection.filteredCount],
|
||||||
|
['Fehlend / unlesbar / leer', inspection.unavailableCount],
|
||||||
|
['Akzeptierte Dateien', inspection.acceptedCount]
|
||||||
|
];
|
||||||
|
return values.map(([label, value]) => `${localizeUiText(label)}: ${Number(value) || 0}`).join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
function existingImportPaths() {
|
function existingImportPaths() {
|
||||||
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
|
return [...selectedFiles.map(file => file.path), ..._pendingFiles.map(file => file.path), ...queueJobs.map(job => job.file)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function coordinateImportEntries(entries) {
|
function coordinateImportEntries(entries, inspectEntries) {
|
||||||
|
const candidates = Array.isArray(entries) ? entries : [];
|
||||||
|
if (candidates.length === 0) return Promise.resolve(null);
|
||||||
|
const generation = _importGeneration;
|
||||||
|
_pendingImportInspections++;
|
||||||
|
syncImportConfirmationState();
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const candidates = Array.isArray(entries) ? entries : [];
|
if (generation !== _importGeneration) return null;
|
||||||
if (candidates.length === 0) return null;
|
|
||||||
let inspection;
|
let inspection;
|
||||||
try {
|
try {
|
||||||
inspection = await window.api.inspectImportFiles(candidates, existingImportPaths());
|
const inspect = typeof inspectEntries === 'function'
|
||||||
|
? inspectEntries
|
||||||
|
: (values, existingPaths) => window.api.inspectImportFiles(values, existingPaths);
|
||||||
|
inspection = await inspect(candidates, existingImportPaths());
|
||||||
} catch {
|
} catch {
|
||||||
|
if (generation !== _importGeneration) return null;
|
||||||
showCopyToast('Vorabprüfung fehlgeschlagen.', 6500);
|
showCopyToast('Vorabprüfung fehlgeschlagen.', 6500);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (generation !== _importGeneration) return null;
|
||||||
mergePendingImportInspection(inspection);
|
mergePendingImportInspection(inspection);
|
||||||
if (inspection.accepted.length > 0) {
|
if (inspection.accepted.length > 0) {
|
||||||
if (document.getElementById('hosterModal')?.style.display === 'flex') {
|
if (document.getElementById('hosterModal')?.style.display === 'flex') {
|
||||||
@@ -1643,23 +1683,24 @@ function coordinateImportEntries(entries) {
|
|||||||
clearDedupKeysForPaths(acceptedPaths);
|
clearDedupKeysForPaths(acceptedPaths);
|
||||||
_pendingFiles.push(...inspection.accepted);
|
_pendingFiles.push(...inspection.accepted);
|
||||||
if (document.getElementById('hosterModal')?.style.display === 'flex') {
|
if (document.getElementById('hosterModal')?.style.display === 'flex') {
|
||||||
syncSelectedUploadHosters();
|
|
||||||
renderHosterModal();
|
renderHosterModal();
|
||||||
} else {
|
} else {
|
||||||
openHosterModal();
|
openHosterModal();
|
||||||
}
|
}
|
||||||
} else if (_pendingFiles.length > 0) {
|
} else if (_pendingFiles.length > 0) {
|
||||||
renderImportPlanSummary();
|
renderImportPlanSummary();
|
||||||
} else if (inspection.candidateCount > 0 && inspection.duplicateCount === inspection.candidateCount) {
|
|
||||||
showCopyToast('Auswahl ist bereits in den Upload-Aufträgen.');
|
|
||||||
_pendingImportInspection = null;
|
|
||||||
} else {
|
} else {
|
||||||
showCopyToast('Keine Dateien wurden akzeptiert.', 6500);
|
showCopyToast(formatRejectedImportBalance(inspection), 6500);
|
||||||
_pendingImportInspection = null;
|
_pendingImportInspection = null;
|
||||||
}
|
}
|
||||||
return inspection;
|
return inspection;
|
||||||
};
|
};
|
||||||
const pending = _importCoordination.then(run, run);
|
const operation = _importCoordination.then(run, run);
|
||||||
|
const pending = operation.finally(() => {
|
||||||
|
if (generation !== _importGeneration) return;
|
||||||
|
_pendingImportInspections = Math.max(0, _pendingImportInspections - 1);
|
||||||
|
renderImportPlanSummary();
|
||||||
|
});
|
||||||
_importCoordination = pending.catch(() => {});
|
_importCoordination = pending.catch(() => {});
|
||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
@@ -1858,7 +1899,7 @@ function buildQueuePreview() {
|
|||||||
for (const file of selectedFiles) {
|
for (const file of selectedFiles) {
|
||||||
for (const hoster of hosters) {
|
for (const hoster of hosters) {
|
||||||
const key = `${file.path}|${hoster}`;
|
const key = `${file.path}|${hoster}`;
|
||||||
if (!existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) {
|
if (window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings) && !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) {
|
||||||
const job = {
|
const job = {
|
||||||
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
file: file.path, fileName: file.name, hoster,
|
file: file.path, fileName: file.name, hoster,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ const { describe, it } = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
getEligibleImportHosters,
|
||||||
inspectImportEntries,
|
inspectImportEntries,
|
||||||
|
inspectReadableImportPath,
|
||||||
summarizeImportPlan
|
summarizeImportPlan
|
||||||
} = require('../lib/import-preflight');
|
} = require('../lib/import-preflight');
|
||||||
|
|
||||||
@@ -81,6 +83,72 @@ describe('import preflight', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the same configured size eligibility for summaries and queue admission', () => {
|
||||||
|
const file = { path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 };
|
||||||
|
const selectedHosters = ['limited.example', 'unlimited.example', 'limited.example'];
|
||||||
|
const hosterSettings = {
|
||||||
|
'limited.example': { maxSizeMb: 3 },
|
||||||
|
'unlimited.example': { maxSizeMb: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(getEligibleImportHosters(file, selectedHosters, hosterSettings), ['unlimited.example']);
|
||||||
|
assert.deepEqual(summarizeImportPlan({
|
||||||
|
inspection: { candidateCount: 1, accepted: [file] },
|
||||||
|
selectedHosters,
|
||||||
|
hosterSettings
|
||||||
|
}), {
|
||||||
|
candidateCount: 1,
|
||||||
|
duplicateCount: 0,
|
||||||
|
filteredCount: 0,
|
||||||
|
unavailableCount: 0,
|
||||||
|
acceptedCount: 1,
|
||||||
|
targetCount: 2,
|
||||||
|
jobCount: 1,
|
||||||
|
sizeLimitedJobCount: 1
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates Windows drive and UNC namespace aliases canonically', async () => {
|
||||||
|
const inspectedPaths = [];
|
||||||
|
const inspection = await inspectImportEntries([
|
||||||
|
'\\\\?\\C:\\incoming\\same.bin',
|
||||||
|
'C:\\incoming\\same.bin',
|
||||||
|
'\\\\?\\UNC\\server\\share\\same.bin',
|
||||||
|
'\\\\server\\share\\same.bin'
|
||||||
|
], {
|
||||||
|
caseInsensitive: true,
|
||||||
|
inspectPath: async filePath => {
|
||||||
|
inspectedPaths.push(filePath);
|
||||||
|
return { exists: true, readable: true, size: 1 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(inspection.acceptedCount, 2);
|
||||||
|
assert.equal(inspection.duplicateCount, 2);
|
||||||
|
assert.deepEqual(inspectedPaths, ['C:\\incoming\\same.bin', '\\\\server\\share\\same.bin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inspects type and size through one opened read handle and closes it', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fileHandle = {
|
||||||
|
stat: async () => {
|
||||||
|
calls.push('stat');
|
||||||
|
return { isFile: () => true, size: 42 };
|
||||||
|
},
|
||||||
|
close: async () => {
|
||||||
|
calls.push('close');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await inspectReadableImportPath('C:\\incoming\\readable.bin', async (filePath, flags) => {
|
||||||
|
calls.push(['open', filePath, flags]);
|
||||||
|
return fileHandle;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { exists: true, readable: true, size: 42 });
|
||||||
|
assert.deepEqual(calls, [['open', 'C:\\incoming\\readable.bin', 'r'], 'stat', 'close']);
|
||||||
|
});
|
||||||
|
|
||||||
it('limits concurrent file inspections', async () => {
|
it('limits concurrent file inspections', async () => {
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let maximumActive = 0;
|
let maximumActive = 0;
|
||||||
|
|||||||
+9
-3
@@ -478,7 +478,7 @@ setTimeout(async () => {
|
|||||||
if (!duplicateDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach();
|
if (!duplicateDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach();
|
||||||
try { fs.unlinkSync(duplicateDropFixture); } catch {}
|
try { fs.unlinkSync(duplicateDropFixture); } catch {}
|
||||||
}
|
}
|
||||||
check('Dropping a file already in the upload jobs explains the duplicate instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Auswahl ist bereits in den Upload-Aufträgen.');
|
check('Dropping a file already in the upload jobs explains the exact duplicate balance instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Kandidaten: 1 · Bereits vorhanden / dupliziert: 1 · Durch Dateinamenfilter ausgeschlossen: 0 · Fehlend / unlesbar / leer: 0 · Akzeptierte Dateien: 0');
|
||||||
|
|
||||||
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
|
||||||
check('Start button disabled initially', startDisabled === true);
|
check('Start button disabled initially', startDisabled === true);
|
||||||
@@ -1577,6 +1577,12 @@ setTimeout(async () => {
|
|||||||
const importPreflightMinimumFit = await wc.executeJavaScript('(() => { const card = document.querySelector("#hosterModal .modal-card"); const summary = document.getElementById("importPlanSummary"); const cardRect = card?.getBoundingClientRect(); return Boolean(cardRect && summary && cardRect.left >= 0 && cardRect.right <= innerWidth && cardRect.top >= 0 && cardRect.bottom <= innerHeight && summary.scrollWidth <= summary.clientWidth + 1 && [...summary.querySelectorAll("dd")].every(value => value.getBoundingClientRect().width > 0)); })()');
|
const importPreflightMinimumFit = await wc.executeJavaScript('(() => { const card = document.querySelector("#hosterModal .modal-card"); const summary = document.getElementById("importPlanSummary"); const cardRect = card?.getBoundingClientRect(); return Boolean(cardRect && summary && cardRect.left >= 0 && cardRect.right <= innerWidth && cardRect.top >= 0 && cardRect.bottom <= innerHeight && summary.scrollWidth <= summary.clientWidth + 1 && [...summary.querySelectorAll("dd")].every(value => value.getBoundingClientRect().width > 0)); })()');
|
||||||
await setWindowBounds(importPreflightBounds);
|
await setWindowBounds(importPreflightBounds);
|
||||||
check('Import preflight remains contained and readable at the minimum window size', importPreflightMinimumFit === true);
|
check('Import preflight remains contained and readable at the minimum window size', importPreflightMinimumFit === true);
|
||||||
|
const importPreflightAdmission = await wc.executeJavaScript('(async () => { await applyHosterSelection(); return { selected: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => ({ file: job.fileName, hoster: job.hoster, status: job.status })) }; })()');
|
||||||
|
check('Configured size-limit pairs never create preview jobs and fully ineligible files leave no selected-file residue', importPreflightAdmission.selected.length === 2 && importPreflightAdmission.selected.includes('Existing.720p.bin') && importPreflightAdmission.selected.includes('Episode.720p.mkv') && !importPreflightAdmission.selected.includes('Large.720p.custom') && importPreflightAdmission.jobs.length === 2 && importPreflightAdmission.jobs.every(job => job.file !== 'Large.720p.custom' && job.hoster === filenameFilterImport.available[0] && job.status === 'preview'));
|
||||||
|
const explicitEmptyHosterSelection = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); selectedFiles = []; _pendingFiles = []; _pendingImportInspection = null; queueJobs = []; rebuildJobIndex(); selectedUploadHosters = getAvailableHosters().slice(0, 1).map(item => item.name); const inspect = entries => ({ candidateCount: entries.length, duplicateCount: 0, filteredCount: 0, unavailableCount: 0, acceptedCount: entries.length, accepted: entries.map(entry => ({ path: entry.path, name: entry.name, size: entry.size })), duplicates: [], filtered: [], unavailable: [] }); await coordinateImportEntries([{ path: fixtures.floatingAccepted, name: "Floating.720p.mkv", size: 7 }], async entries => inspect(entries)); const selected = document.querySelector("input[data-hoster-modal]:checked"); selected?.click(); let releaseSecond; const second = coordinateImportEntries([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7 }], async entries => new Promise(resolve => { releaseSecond = () => resolve(inspect(entries)); })); const disabledWhilePending = document.getElementById("confirmHosterModalBtn").disabled; for (let index = 0; index < 20 && !releaseSecond; index++) await Promise.resolve(); releaseSecond?.(); await second; return { disabledWhilePending, checked: [...document.querySelectorAll("input[data-hoster-modal]:checked")].map(input => input.dataset.hosterModal), targets: Number(document.getElementById("importPlanTargets").textContent) }; })()');
|
||||||
|
check('A later inspection preserves an explicitly empty hoster selection and confirmation stays disabled while it is pending', explicitEmptyHosterSelection.disabledWhilePending === true && explicitEmptyHosterSelection.checked.length === 0 && explicitEmptyHosterSelection.targets === 0);
|
||||||
|
const cancelledImportGeneration = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); selectedFiles = []; _pendingFiles = []; _pendingImportInspection = null; queueJobs = []; rebuildJobIndex(); selectedUploadHosters = getAvailableHosters().slice(0, 1).map(item => item.name); const result = entry => ({ candidateCount: 1, duplicateCount: 0, filteredCount: 0, unavailableCount: 0, acceptedCount: 1, accepted: [{ path: entry.path, name: entry.name, size: entry.size }], duplicates: [], filtered: [], unavailable: [] }); let runningCalls = 0; let queuedCalls = 0; let releaseRunning; const runningEntry = { path: fixtures.floatingAccepted, name: "Floating.720p.mkv", size: 7 }; const queuedEntry = { path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7 }; const running = coordinateImportEntries([runningEntry], async () => { runningCalls++; return new Promise(resolve => { releaseRunning = () => resolve(result(runningEntry)); }); }); for (let index = 0; index < 20 && !releaseRunning; index++) await Promise.resolve(); const queued = coordinateImportEntries([queuedEntry], async () => { queuedCalls++; return result(queuedEntry); }); const disabledWhilePending = document.getElementById("confirmHosterModalBtn").disabled; cancelHosterModal(); releaseRunning?.(); await Promise.all([running, queued]); await new Promise(resolve => setTimeout(resolve, 25)); return { runningCalls, queuedCalls, disabledWhilePending, modal: document.getElementById("hosterModal").style.display, pending: _pendingFiles.length, inspection: _pendingImportInspection }; })()');
|
||||||
|
check('Cancelling invalidates running and queued results from the old import generation', cancelledImportGeneration.runningCalls === 1 && cancelledImportGeneration.queuedCalls === 0 && cancelledImportGeneration.disabledWhilePending === true && cancelledImportGeneration.modal !== 'flex' && cancelledImportGeneration.pending === 0 && cancelledImportGeneration.inspection === null);
|
||||||
const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); await addDropTargetEntries([{ path: fixtures.floatingAccepted }, { path: fixtures.floatingFiltered }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7, type: "video/x-matroska" }, { path: fixtures.desktopFiltered, name: "Desktop.1080p.mkv", size: 7, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()');
|
const filenameFilterDropPaths = await wc.executeJavaScript('(async () => { const fixtures = ' + JSON.stringify(importPreflightFixtures) + '; cancelHosterModal(); await addDropTargetEntries([{ path: fixtures.floatingAccepted }, { path: fixtures.floatingFiltered }]); const floating = _pendingFiles.map(file => file.name); cancelHosterModal(); await addDroppedFiles([{ path: fixtures.desktopAccepted, name: "Desktop.720p.mkv", size: 7, type: "video/x-matroska" }, { path: fixtures.desktopFiltered, name: "Desktop.1080p.mkv", size: 7, type: "video/x-matroska" }]); const desktop = _pendingFiles.map(file => file.name); cancelHosterModal(); return { floating, desktop }; })()');
|
||||||
check('Filename filter applies identically to floating and native desktop drops', JSON.stringify(filenameFilterDropPaths.floating) === JSON.stringify(['Floating.720p.mkv']) && JSON.stringify(filenameFilterDropPaths.desktop) === JSON.stringify(['Desktop.720p.mkv']));
|
check('Filename filter applies identically to floating and native desktop drops', JSON.stringify(filenameFilterDropPaths.floating) === JSON.stringify(['Floating.720p.mkv']) && JSON.stringify(filenameFilterDropPaths.desktop) === JSON.stringify(['Desktop.720p.mkv']));
|
||||||
await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); config.globalSettings.folderMonitor = { ...(config.globalSettings.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false }; })()');
|
await wc.executeJavaScript('(() => { selectedFiles = []; _pendingFiles = []; queueJobs = []; rebuildJobIndex(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); config.globalSettings.folderMonitor = { ...(config.globalSettings.folderMonitor || {}), hosters: ["voe.sx"], autoStart: false }; })()');
|
||||||
@@ -1585,8 +1591,8 @@ setTimeout(async () => {
|
|||||||
const filenameFilterFolderMonitor = await wc.executeJavaScript('(() => ({ files: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => job.fileName), modal: document.getElementById("hosterModal")?.style.display, toast: document.getElementById("copyToast")?.textContent }))()');
|
const filenameFilterFolderMonitor = await wc.executeJavaScript('(() => ({ files: selectedFiles.map(file => file.name), jobs: queueJobs.map(job => job.fileName), modal: document.getElementById("hosterModal")?.style.display, toast: document.getElementById("copyToast")?.textContent }))()');
|
||||||
check('Filename filter also applies to monitored folders with preset destinations', JSON.stringify(filenameFilterFolderMonitor.files) === JSON.stringify(['Watched.720p.mkv']) && filenameFilterFolderMonitor.jobs.every(name => name === 'Watched.720p.mkv') && filenameFilterFolderMonitor.modal !== 'flex' && /1 von 2/.test(filenameFilterFolderMonitor.toast || ''));
|
check('Filename filter also applies to monitored folders with preset destinations', JSON.stringify(filenameFilterFolderMonitor.files) === JSON.stringify(['Watched.720p.mkv']) && filenameFilterFolderMonitor.jobs.every(name => name === 'Watched.720p.mkv') && filenameFilterFolderMonitor.modal !== 'flex' && /1 von 2/.test(filenameFilterFolderMonitor.toast || ''));
|
||||||
await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView()');
|
await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); updateUploadView()');
|
||||||
const filenameFilterRejectAll = await wc.executeJavaScript('(async () => { const rejected = ' + JSON.stringify(importPreflightFixtures.rejected) + '; cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: toast.textContent, shown: toast.classList.contains("show") }; })()');
|
const filenameFilterRejectAll = await wc.executeJavaScript('(async () => { const rejected = ' + JSON.stringify(importPreflightFixtures.rejected) + '; cancelHosterModal(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); const action = document.getElementById("filenameFilterActionInput"); action.value = "exclude"; action.dispatchEvent(new Event("change", { bubbles: true })); const rows = [...document.querySelectorAll("[data-filename-filter-condition]")]; rows[0].querySelector("[data-filename-filter-value]").value = "1080p"; rows[0].querySelector("[data-filename-filter-value]").dispatchEvent(new Event("input", { bubbles: true })); rows.slice(1).forEach(row => row.querySelector("[data-filename-filter-remove]")?.click()); config.globalSettings.filenameFilter = readFilenameFilterSettings(); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); const german = toast.textContent; setUiLanguage("en"); toast.textContent = ""; toast.classList.remove("show"); await addPathsToQueue([{ path: rejected, name: "Only.1080p.mkv", size: 7 }]); const english = toast.textContent; setUiLanguage("de"); return { modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, german, english, shown: toast.classList.contains("show") }; })()');
|
||||||
check('A fully excluded import stays out of the queue and explains the result', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && filenameFilterRejectAll.toast === 'Keine Dateien wurden akzeptiert.');
|
check('A fully excluded import stays out of the queue and keeps an exact bilingual balance visible', filenameFilterRejectAll.modal !== 'flex' && filenameFilterRejectAll.pending === 0 && filenameFilterRejectAll.shown === true && filenameFilterRejectAll.german === 'Kandidaten: 1 · Bereits vorhanden / dupliziert: 0 · Durch Dateinamenfilter ausgeschlossen: 1 · Fehlend / unlesbar / leer: 0 · Akzeptierte Dateien: 0' && filenameFilterRejectAll.english === 'Candidates: 1 · Already present / duplicated: 0 · Excluded by filename filter: 1 · Missing / unreadable / empty: 0 · Accepted files: 0');
|
||||||
await wc.executeJavaScript('hosterSettings = window.__importPreflightHosterSettings; delete window.__importPreflightHosterSettings; true');
|
await wc.executeJavaScript('hosterSettings = window.__importPreflightHosterSettings; delete window.__importPreflightHosterSettings; true');
|
||||||
fs.rmSync(importPreflightFolder, { recursive: true, force: true });
|
fs.rmSync(importPreflightFolder, { recursive: true, force: true });
|
||||||
await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()');
|
await wc.executeJavaScript('(() => { const enabled = document.getElementById("filenameFilterEnabledInput"); enabled.checked = false; enabled.dispatchEvent(new Event("change", { bubbles: true })); return saveSettings({ feedbackText: "Gespeichert" }); })()');
|
||||||
|
|||||||
Reference in New Issue
Block a user