docs: publish complete product guide and UI fixes

Expand the English README into a practical guide covering installation, accounts, queue controls, automation, history, backups, updates, troubleshooting, and development. Add four sanitized workflow screenshots and enforce their exact public manifest. Restore the animated Backup submenu outside its parent menu, keep expanded account groups naturally sized in the scrolling Accounts list, and strengthen Electron UI regression exit codes.
This commit is contained in:
Sucukdeluxe
2026-08-10 21:05:27 +02:00
parent b6fce38b8b
commit 7897e2c622
11 changed files with 446 additions and 51 deletions
+240 -27
View File
@@ -1,49 +1,262 @@
# Multi-Hoster-Upload # Multi-Hoster-Upload
Multi-Hoster-Upload is a Windows desktop app for managing large file batches across multiple video hosting services from one queue. Multi-Hoster-Upload is a Windows desktop application for sending file batches to several supported video hosts from one queue. Add files or folders, choose one or more destinations, manage multiple accounts, and follow every upload from preparation to its final link.
![Multi-Hoster-Upload product overview](assets/product-overview.png) ![Multi-Hoster-Upload product overview](assets/product-overview.png)
[Download the latest release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest) ## Download
## Capabilities Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
- Upload one batch to several supported hosters in parallel. The latest public release is version 2.1.1. Use the release page for the executables and the full English changelog.
- Manage multiple accounts per hoster with validation, health checks, automatic rotation, and inline OTP completion.
- Filter uploads, accounts, and history from task-focused sidebars without changing the underlying queue.
- Add files by drag and drop or file selection and monitor live queue progress.
- Use the complete interface in English or German and switch languages without restarting the app.
- Control per-hoster concurrency, bandwidth limits, retries, folder monitoring, notifications, and completed-item cleanup.
- Keep local upload history, choose a retention period, and copy completed links in bulk.
- Transfer accounts and settings with a 75-character encrypted online key while encryption and decryption stay on the client.
- Check for updates from Settings or Help; when a newer release is available, install it from the header update action and accessible update dialog.
## Supported hosters ## Features
| Hoster | Authentication | ### Upload workspace
| --- | --- |
| Doodstream | Web login or API key |
| VOE | Web login or API key |
| Vidmoly | Web login |
| Byse | API key |
| Clouddrop | API key |
## Installation - Add individual files, complete folders, or files by drag and drop.
- Build one job per selected file and destination.
- Upload to several supported hosts from the same queue.
- Filter the workspace by all, active, queued, completed, or failed jobs.
- Track status, progress, transferred size, speed, and the selected host account.
- Reorder selected jobs, start selected jobs, retry finished jobs, or stop active work.
- Copy completed links individually or together.
1. Download the Setup or portable executable from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). ### Accounts and automation
2. Run the installer, or launch the portable executable directly.
3. Add and validate at least one hoster account in Accounts, then select files and start the queue.
## Local data and credentials - Keep multiple named accounts for each host.
- Validate credentials before a new or edited account is saved.
- Run health checks for one account or all configured accounts.
- Complete an OTP check in the account view when a host requests it.
- Enable, disable, prioritize, and reorder accounts.
- Rotate files across enabled accounts or keep the first enabled account as the primary account.
- Switch to an available fallback account when an account-specific upload error is detected.
- Apply retries, concurrency, bandwidth, file-size, and pacing settings per host.
- Monitor a folder for new files and start matching uploads automatically.
Settings, queue state, and upload history are stored locally in the app's user-data directory. Hoster passwords and API keys are encrypted with Electron safeStorage before being written when operating-system encryption is available; on Windows this uses DPAPI for the current user profile. Online backups are optional, contain accounts and settings only, and are encrypted on the client before the server receives them. Upload history and queue state remain on the original device. ### History, transfer, and updates
## Development - Restore unfinished queue entries after restarting the application.
- Keep upload history entries and filter the archive by all, successful, or failed views.
- Retain all history, a time window, or the latest 100 or 1,000 uploads.
- Export history as CSV or JSON.
- Use the complete interface in English or German and switch at runtime.
- Export settings locally or transfer them with an encrypted online backup key.
- Send a batch summary to a Discord webhook or another HTTP webhook.
- Check for application updates and read the matching GitHub release changelog in the update dialog.
## Supported hosts and authentication
| Host | Web login | API key |
| --- | :---: | :---: |
| Doodstream | Yes | Yes |
| VOE | Yes | Yes |
| Vidmoly | Yes | No |
| Byse | No | Yes |
| Clouddrop | No | Yes |
Doodstream uses the API key when an account contains both an API key and web-login credentials. VOE web login expects an email address. Only enabled accounts with the required credentials are available as upload destinations.
## Install or run portable
The release page contains two Windows builds:
- **Setup** installs the application for the current Windows user. It lets you choose the installation directory and can create a desktop shortcut.
- **Portable** runs directly without an installer. Move the executable wherever you want and launch it from there.
Both builds use the same application features. Portable refers to the executable package; application settings, queue state, and history still use Electron's user-data directory rather than a folder beside the executable.
## First upload
![Empty upload workspace](docs/screenshots/upload-workspace.png)
1. Open **Accounts** and select **Add account**.
2. Choose the host and authentication method shown in the table above.
3. Enter the credentials and select **Verify and add**. If the host requests an OTP, enter it before continuing.
4. Return to **Uploads** and add files, add a folder, or drop files into the workspace.
5. Select one or more available destinations. The application creates a queue preview for each file and host combination.
6. Review the jobs and start the complete queue or only the selected jobs.
7. Follow progress in the queue. Completed rows expose their generated links, which can be opened or copied.
Files and folders are checked before upload. A job can also be skipped by a configured maximum file size before any upload starts.
## Account management and rotation
![Account management](docs/screenshots/account-management.png)
Each host can contain several accounts. Accounts can have a label, can be enabled or disabled, and can be reordered within their host group. The first enabled account with usable credentials is the primary account.
Turn on **Rotate accounts** in a host's upload settings to distribute files round-robin across all enabled accounts for that host. With rotation off, the primary account remains selected. Rotation has no effect when only one usable account exists.
Account checks show ready, warning, OTP-required, checking, or failed states. An automatic check runs when the application starts, and checks can also be started manually for one account or all accounts. The application does not run a separate account check immediately before each upload batch. During a batch, account-specific failures can mark an account unavailable for that session and move work to a usable fallback. Transient network or host errors stay in the normal retry path instead of disabling the account.
## Queue, concurrency, rate controls, and recovery
### Concurrency and bandwidth
The application combines global controls with settings for each host:
- **Global parallel uploads:** `0` keeps only the per-host limits; `1` to `100` caps total concurrency across hosts.
- **Per-host parallel uploads:** limits simultaneous jobs for that host from `1` to `100`.
- **Global speed limit:** caps combined upload throughput in MB/s; `0` is unlimited.
- **Per-host speed limit:** caps that host's throughput in MB/s; `0` is unlimited.
- **Interval:** waits the configured number of seconds between jobs for a host.
- **Restart below:** restarts an upload when its measured speed remains below the configured kB/s threshold; `0` disables the check.
- **Maximum size:** skips files above the per-host MB limit; `0` is unlimited.
When automatic scaling is enabled and a global parallel limit is set, each per-host parallel limit is capped at that global limit. It does not distribute parallel slots across accounts.
### Retries and recovery
Each host has its own retry count. A job reports the retrying state while another attempt is being prepared. After a batch ends, optional automatic retry rounds can retry transient failures with a progressively longer delay.
Queue state is saved while you work and again during a normal application close. When **Restore queue at launch** is enabled, unfinished jobs return as ready queue entries after a restart. Start the restored jobs manually. Jobs that the upload log proves were already completed after the saved snapshot are removed from the restored queue to avoid an immediate duplicate.
Successful jobs can remain visible or be removed automatically. History is stored separately, so removing a completed row from the active queue does not remove its history entry.
## Folder monitoring
![Automation and folder monitoring settings](docs/screenshots/automation-settings.png)
Folder monitoring watches for new files while the application is running. Configure it under **Settings > Automation**:
1. Choose the folder and decide whether subfolders are included.
2. Use an include or exclude extension list such as `mp4,mkv`.
3. Set a write-completion delay so partially copied files are not queued too early.
4. Choose whether duplicate file events are ignored during the current monitoring session.
5. Select the destination hosts and decide whether matching jobs start automatically.
6. Enable monitoring and save the settings.
The watcher ignores files that already exist when monitoring starts. New matching files are collected after they have remained stable for the configured delay. If no hosts are preselected, the application asks for destinations instead of starting a batch automatically.
## History, retention, links, and export
![Upload history](docs/screenshots/history.png)
History keeps completed, failed, stopped, and skipped results in a separate archive. Only a `done` result represents a successful upload; a `skipped` result records a job that was not uploaded. The current **Successful** view also includes skipped jobs even though they were not uploaded. Filter the archive by all, successful, or failed views; sort the table; open a generated link; or copy one link directly from its row.
Retention choices are:
- Keep everything.
- Keep the last 7, 30, or 90 days.
- Keep the latest 100 or 1,000 uploads.
Changing to a stricter rule previews how many rows will be removed and asks for confirmation. New history entries are pruned against the active rule. **Export history** saves the archive as CSV or JSON. Deleting history is permanent and requires confirmation.
## English and German
English is the default language for new profiles. Open **Settings > General** to switch between English and German. The visible interface updates immediately without restarting the application, including dynamically rendered lists, dialogs, status text, placeholders, titles, and accessibility labels. The selected language is saved for the next launch.
## Backup, security, and privacy
### Local data and credentials
Settings, pending queue state, and upload history are stored in Electron's user-data directory. Passwords and API keys are encrypted with Electron `safeStorage` before they are written when operating-system encryption is available and encryption succeeds. On Windows, `safeStorage` uses DPAPI and ties encrypted values to the current Windows user profile. If operating-system encryption is unavailable or encryption fails, passwords and API keys are stored as plaintext on disk.
Credentials are decrypted when required for account validation or upload to the selected host. Do not share application data files, backup files, screenshots containing credentials, or generated backup keys.
### Local backup
The Backup menu can export and import accounts and settings:
- `.mhu` uses an authenticated AES-256-GCM envelope with an app-wide built-in key. This detects modified or corrupted data and prevents accidental plaintext viewing, but it does not protect the backup from someone who has the application or its source code.
- `.json` is an explicit plaintext export. It can contain host credentials and must be protected accordingly.
Both backup formats can contain credentials. Protect `.mhu` files like credentials even though their contents are not directly readable as plaintext. Local backups exclude upload history and the pending queue. When importing on another computer, unavailable log or monitored-folder paths are cleared; folder monitoring is disabled if its saved path does not exist.
### Encrypted online transfer
Online backup is optional and transfers accounts and settings only. The application creates a 75-character key, encrypts the backup on the client with AES-256-GCM, and uploads only the encrypted blob. The service does not receive the decryption key. Queue state and history are not included.
Treat the generated key like a password: anyone with it can restore the encrypted settings. Creating a new key does not invalidate older keys.
### Other network activity
- Uploads and credential checks communicate with the host you selected.
- Update checks retrieve release metadata, and the dialog loads the matching changelog from GitHub when available.
- Webhook notifications are sent only when you configure a webhook URL. Discord endpoints receive a formatted summary; other endpoints receive JSON.
- Online backup traffic occurs only when you create or restore an online backup.
## Updates and changelog
Open **Settings > General** or **Help** and select **Check for updates**. When a newer version is available, the application shows the installed and available versions, download progress, and release notes. The update dialog requests the changelog for the matching version from the public GitHub release and uses the release's fallback description if that changelog is unavailable.
The downloaded Setup executable is checked as a Windows executable. When `latest.yml` contains a SHA-512 value, the downloaded file is also checked against it before installation starts.
You can always install manually from the [GitHub releases page](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases).
## Troubleshooting
### No destination is available
- Open **Accounts** and confirm that at least one account is enabled.
- Check that its authentication method matches the supported-host table.
- Run the account check again and complete any requested OTP step.
- Add the account again if validation fails; invalid new credentials are not saved.
### The upload button stays disabled
- Add at least one file or folder.
- Select at least one host with an enabled account and usable credentials.
- Check whether a configured maximum file size skipped every preview job.
### Uploads retry or fail repeatedly
- Open the job log for the concrete host response.
- Recheck the account in **Accounts**.
- Reduce global or per-host concurrency.
- Review the per-host retry count, speed threshold, interval, and size limit.
- A low-speed restart reuploads the job; disable **Restart below** by setting it to `0` when it is not needed.
### A queue was interrupted
- Keep **Restore queue at launch** enabled before closing the application.
- Restart the application and review the restored ready jobs.
- Start those jobs manually after confirming the files still exist.
- Completed jobs may be removed automatically when they are already present in the upload log.
### Folder monitoring does not add a file
- Confirm that monitoring is enabled and the selected folder still exists.
- Remember that existing files are ignored when the watcher starts.
- Check the include or exclude extension list and the subfolder option.
- Wait for the configured write-completion delay.
- Confirm that at least one destination host is selected if automatic start is enabled.
### History or backup export does not appear
- History export is unavailable when the history is empty.
- Check the destination selected in the Windows save dialog.
- Both `.mhu` and `.json` backups can contain credentials. Protect either format like credentials.
- `.mhu` uses AES-256-GCM with an app-wide built-in key. It prevents direct plaintext viewing but does not protect the backup from someone who has the application or its source code. `.json` stores the same backup data as plaintext.
- Queue state and history are not part of settings backups.
### Update checking fails
- Check the internet connection and try again after active uploads finish.
- Use the [latest release page](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest) for a manual download.
- If the changelog cannot be loaded, the application can still show the fallback release description when one is available.
## Development and Windows builds
Install dependencies and start the Electron application:
```powershell ```powershell
npm install npm install
npm start npm start
```
Run the test suite and lint checks:
```powershell
npm test npm test
npm run lint npm run lint
```
Create both Windows release targets without publishing them:
```powershell
npm run release:win npm run release:win
``` ```
The release command builds the NSIS Setup executable and the Portable executable into `release/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+2 -1
View File
@@ -127,6 +127,7 @@
['Hoster-Zugangsdaten verwalten und prüfen', 'Manage and verify host account credentials'], ['Hoster-Zugangsdaten verwalten und prüfen', 'Manage and verify host account credentials'],
['Accounts prüfen', 'Check accounts'], ['Accounts prüfen', 'Check accounts'],
['Account hinzufügen', 'Add account'], ['Account hinzufügen', 'Add account'],
['Noch keine Hoster', 'No hosts yet'],
['Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.', 'Add your first host account. Credentials are verified before saving.'], ['Füge deinen ersten Hoster-Account hinzu. Die Zugangsdaten werden vor dem Speichern geprüft.', 'Add your first host account. Credentials are verified before saving.'],
['Alle ausklappen', 'Expand all'], ['Alle ausklappen', 'Expand all'],
['Alle einklappen', 'Collapse all'], ['Alle einklappen', 'Collapse all'],
@@ -510,7 +511,7 @@
const textSources = new WeakMap(); const textSources = new WeakMap();
const textRendered = new WeakMap(); const textRendered = new WeakMap();
const attributeSources = new WeakMap(); const attributeSources = new WeakMap();
const attributes = ['aria-label', 'placeholder', 'title', 'data-tooltip']; const attributes = ['aria-label', 'placeholder', 'title', 'data-tooltip', 'data-empty-label'];
let language = 'en'; let language = 'en';
let observer = null; let observer = null;
+1 -1
View File
@@ -380,7 +380,7 @@
</nav> </nav>
<div class="view-sidebar-section view-sidebar-hoster-section"> <div class="view-sidebar-section view-sidebar-hoster-section">
<span class="view-sidebar-section-label">Hoster</span> <span class="view-sidebar-section-label">Hoster</span>
<div class="view-sidebar-hosters" id="accountsSidebarHosters"></div> <div class="view-sidebar-hosters" id="accountsSidebarHosters" data-empty-label="Noch keine Hoster"></div>
</div> </div>
<div class="view-sidebar-footnote">Zugänge prüfen, priorisieren und für Uploads bereitstellen.</div> <div class="view-sidebar-footnote">Zugänge prüfen, priorisieren und für Uploads bereitstellen.</div>
</aside> </aside>
+3 -2
View File
@@ -2083,7 +2083,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
} }
.menu-opening { .menu-opening {
animation: menu-unfold 180ms cubic-bezier(.2, .8, .2, 1) both; animation: menu-unfold 180ms cubic-bezier(.2, .8, .2, 1);
} }
.menu-closing { .menu-closing {
@@ -3201,6 +3201,7 @@ input[type="checkbox"] {
min-height: 0; min-height: 0;
padding: 12px 16px 16px; padding: 12px 16px 16px;
gap: 8px; gap: 8px;
grid-auto-rows: max-content;
overflow: auto; overflow: auto;
} }
@@ -3244,7 +3245,7 @@ input[type="checkbox"] {
} }
.view-sidebar-hosters:empty::after { .view-sidebar-hosters:empty::after {
content: "Noch keine Hoster"; content: attr(data-empty-label);
color: var(--text-dim); color: var(--text-dim);
font-size: 10px; font-size: 10px;
} }
+13 -5
View File
@@ -124,8 +124,14 @@ const sourceFiles = [
'tests/validate-credentials.test.js', 'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js' 'tests/webhook-notify.test.js'
]; ];
const screenshotFile = 'assets/product-overview.png'; const screenshotFiles = [
const allowedFiles = new Set([...sourceFiles, screenshotFile]); 'assets/product-overview.png',
'docs/screenshots/upload-workspace.png',
'docs/screenshots/account-management.png',
'docs/screenshots/automation-settings.png',
'docs/screenshots/history.png'
];
const allowedFiles = new Set([...sourceFiles, ...screenshotFiles]);
const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']); const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', '.mjs', '.txt', '.yaml', '.yml']);
const binaryExtensions = new Set(['.ico', '.png']); const binaryExtensions = new Set(['.ico', '.png']);
const expectedScripts = { const expectedScripts = {
@@ -354,8 +360,9 @@ function validateServicePackage(packageJson, packageLock) {
} }
} }
async function validateScreenshot(sourceOnly) { async function validateScreenshots(sourceOnly) {
if (sourceOnly) return; if (sourceOnly) return;
for (const screenshotFile of screenshotFiles) {
try { try {
const data = await readFile(path.join(root, screenshotFile)); const data = await readFile(path.join(root, screenshotFile));
const signature = data.subarray(0, 8).toString('hex'); const signature = data.subarray(0, 8).toString('hex');
@@ -367,6 +374,7 @@ async function validateScreenshot(sourceOnly) {
} catch { } catch {
addFailure(screenshotFile, 'required-screenshot'); addFailure(screenshotFile, 'required-screenshot');
} }
}
} }
function printFailures() { function printFailures() {
@@ -380,7 +388,7 @@ function printFailures() {
async function main() { async function main() {
const { sourceOnly, expectedVersion } = parseArguments(); const { sourceOnly, expectedVersion } = parseArguments();
const files = await enumerate(); const files = await enumerate();
const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, screenshotFile]; const requiredFiles = sourceOnly ? sourceFiles : [...sourceFiles, ...screenshotFiles];
for (const requiredFile of requiredFiles) { for (const requiredFile of requiredFiles) {
if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file'); if (!files.includes(requiredFile)) addFailure(requiredFile, 'required-source-file');
} }
@@ -392,7 +400,7 @@ async function main() {
const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json'); const serviceLock = await readJson('services/backup-api/package-lock.json', 'service-package-lock-json');
validatePackage(packageJson, packageLock, files, expectedVersion); validatePackage(packageJson, packageLock, files, expectedVersion);
validateServicePackage(servicePackage, serviceLock); validateServicePackage(servicePackage, serviceLock);
await validateScreenshot(sourceOnly); await validateScreenshots(sourceOnly);
if (failures.size > 0) { if (failures.size > 0) {
printFailures(); printFailures();
+59 -4
View File
@@ -17,8 +17,15 @@ const rootFiles = [
'preload-drop-target.js', 'preload-drop-target.js',
'preload.js' 'preload.js'
]; ];
const directoryRoots = ['assets', 'lib', 'renderer', 'services/backup-api', 'tests']; const directoryRoots = ['assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs']; const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
const screenshotFiles = [
'assets/product-overview.png',
'docs/screenshots/upload-workspace.png',
'docs/screenshots/account-management.png',
'docs/screenshots/automation-settings.png',
'docs/screenshots/history.png'
];
const currentVersion = require('../package.json').version; const currentVersion = require('../package.json').version;
function copyDirectory(source, destination) { function copyDirectory(source, destination) {
@@ -32,6 +39,14 @@ function copyDirectory(source, destination) {
} }
} }
function copyDocumentationScreenshots(stage) {
for (const relativePath of screenshotFiles.slice(1)) {
const destination = path.join(stage, relativePath);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(root, screenshotFiles[0]), destination);
}
}
function createStage() { function createStage() {
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-')); const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
for (const relativePath of rootFiles) { for (const relativePath of rootFiles) {
@@ -39,7 +54,10 @@ function createStage() {
fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(root, relativePath), destination); fs.copyFileSync(path.join(root, relativePath), destination);
} }
for (const relativePath of directoryRoots) copyDirectory(path.join(root, relativePath), path.join(stage, relativePath)); for (const relativePath of directoryRoots) {
if (relativePath === 'docs') copyDocumentationScreenshots(stage);
else copyDirectory(path.join(root, relativePath), path.join(stage, relativePath));
}
for (const relativePath of scriptFiles) { for (const relativePath of scriptFiles) {
const destination = path.join(stage, relativePath); const destination = path.join(stage, relativePath);
fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.mkdirSync(path.dirname(destination), { recursive: true });
@@ -49,8 +67,11 @@ function createStage() {
return stage; return stage;
} }
function verify(stage, version = currentVersion) { function verify(stage, version = currentVersion, sourceOnly = true) {
return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], { const args = ['scripts/verify-public-release.mjs'];
if (sourceOnly) args.push('--source-only');
args.push('--version', version);
return spawnSync(process.execPath, args, {
cwd: stage, cwd: stage,
encoding: 'utf8' encoding: 'utf8'
}); });
@@ -70,7 +91,41 @@ test('public release verifier accepts only the exact source manifest and target
assert.match(extra.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/); assert.match(extra.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
fs.rmSync(path.join(stage, 'tests', 'unexpected.json')); fs.rmSync(path.join(stage, 'tests', 'unexpected.json'));
fs.copyFileSync(path.join(stage, screenshotFiles[1]), path.join(stage, 'docs', 'screenshots', 'unexpected.png'));
const extraDocumentation = verify(stage);
assert.equal(extraDocumentation.status, 1);
assert.match(extraDocumentation.stderr, /docs\/screenshots\/unexpected\.png\tsource-layout-allowlist/);
fs.rmSync(path.join(stage, 'docs', 'screenshots', 'unexpected.png'));
const wrongVersion = verify(stage, '2.0.5'); const wrongVersion = verify(stage, '2.0.5');
assert.equal(wrongVersion.status, 1); assert.equal(wrongVersion.status, 1);
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/); assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
}); });
test('public release verifier requires and validates every approved screenshot', (t) => {
const stage = createStage();
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
fs.copyFileSync(path.join(root, screenshotFiles[0]), path.join(stage, screenshotFiles[0]));
const baseline = verify(stage, currentVersion, false);
assert.equal(baseline.status, 0, baseline.stderr);
for (const relativePath of screenshotFiles) {
const absolutePath = path.join(stage, relativePath);
const original = fs.readFileSync(absolutePath);
const invalid = Buffer.from(original);
invalid.writeUInt32BE(999, 16);
fs.writeFileSync(absolutePath, invalid);
const invalidScreenshot = verify(stage, currentVersion, false);
assert.equal(invalidScreenshot.status, 1);
assert.ok(invalidScreenshot.stderr.includes(`${relativePath}\tproduct-screenshot`), invalidScreenshot.stderr);
fs.writeFileSync(absolutePath, original);
}
const missingPath = screenshotFiles.at(-1);
fs.rmSync(path.join(stage, missingPath));
const missingScreenshot = verify(stage, currentVersion, false);
assert.equal(missingScreenshot.status, 1);
assert.ok(missingScreenshot.stderr.includes(`${missingPath}\trequired-screenshot`), missingScreenshot.stderr);
});
+119 -2
View File
@@ -178,6 +178,10 @@ setTimeout(async () => {
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History'); check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()'); const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true); check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
await wc.executeJavaScript('document.querySelector(".tab[data-view=accounts]")?.click()');
const englishEmptyAccountHosterLabel = await wc.executeJavaScript('(() => { const container = document.getElementById("accountsSidebarHosters"); return [container?.getAttribute("data-empty-label"), getComputedStyle(container, "::after").content].join("|"); })()');
check('Empty account hoster sidebar renders its localized English label', englishEmptyAccountHosterLabel === 'No hosts yet|"No hosts yet"');
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()'); const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()');
check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de'); check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de');
const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))'); const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
@@ -241,6 +245,16 @@ setTimeout(async () => {
const submenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector(".menu-submenu-dropdown"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()'); const submenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector(".menu-submenu-dropdown"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()');
check('Nested header menu visibly unfolds from top to bottom', submenuOpeningMotion === 'true|true|true|true'); check('Nested header menu visibly unfolds from top to bottom', submenuOpeningMotion === 'true|true|true|true');
await new Promise(resolve => setTimeout(resolve, 160)); await new Promise(resolve => setTimeout(resolve, 160));
const menuWindowBounds = win.getBounds();
const submenuReachability = {};
for (const [label, width, height] of [['standard', 1100, 750], ['minimum', 800, 550]]) {
win.setSize(width, height);
await new Promise(resolve => setTimeout(resolve, 80));
submenuReachability[label] = await wc.executeJavaScript('(() => { const parent = document.querySelector("[data-menu-dropdown=datei]"); const target = document.querySelector(".menu-submenu-dropdown [data-menu-action=backup-export]"); if (!parent || !target) return "missing"; const rect = target.getBoundingClientRect(); const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); return [getComputedStyle(parent).clipPath, hit === target || target.contains(hit)].join("|"); })()');
}
win.setBounds(menuWindowBounds);
check('Backup submenu is painted and reachable at the standard window size', submenuReachability.standard === 'none|true');
check('Backup submenu is painted and reachable at the minimum window size', submenuReachability.minimum === 'none|true');
await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))'); await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))');
await new Promise(resolve => setTimeout(resolve, 60)); await new Promise(resolve => setTimeout(resolve, 60));
const mainMenuClosingMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-closing"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip)].join("|"); })()'); const mainMenuClosingMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-closing"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip)].join("|"); })()');
@@ -506,6 +520,108 @@ setTimeout(async () => {
const fallbackAccountFocus = await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger.focus(); trigger.click(); document.querySelector("[data-account-empty-add]")?.remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); return document.activeElement?.id; })()'); const fallbackAccountFocus = await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger.focus(); trigger.click(); document.querySelector("[data-account-empty-add]")?.remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); return document.activeElement?.id; })()');
check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn'); check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn');
win.setSize(1280, 720);
await new Promise(resolve => setTimeout(resolve, 80));
const emptyAccountsGeometry = await wc.executeJavaScript(\`(() => {
HOSTERS.forEach(name => { config.hosters[name] = []; });
accountStatuses = {};
renderAccounts();
const list = document.getElementById('accountsList');
return {
emptyVisible: Boolean(list?.querySelector('.accounts-empty')),
contained: Boolean(list && list.scrollHeight <= list.clientHeight + 1)
};
})()\`);
check('Empty account state remains contained at 1280x720', emptyAccountsGeometry.emptyVisible && emptyAccountsGeometry.contained);
await captureVisual('02-accounts-empty-1280x720.png');
const tallAccountGroupGeometry = await wc.executeJavaScript(\`(() => {
const hoster = HOSTERS[0];
HOSTERS.forEach(name => { config.hosters[name] = []; });
config.hosters[hoster] = [1, 2, 3, 4].map(index => ({
id: 'ui-overflow-tall-' + index,
label: 'Fictional account ' + index,
enabled: true,
authType: 'login',
username: 'tall-' + index + '@example.invalid',
password: 'fictional-password-' + index
}));
accountStatuses = Object.fromEntries(config.hosters[hoster].map(account => [account.id, { status: 'ok', message: 'Bereit' }]));
renderAccounts();
document.querySelector('[data-hoster-toggle]')?.click();
document.querySelector('[data-hoster-settings-toggle]')?.click();
const list = document.getElementById('accountsList');
const group = list?.querySelector('.account-hoster-group');
if (list) list.scrollTop = list.scrollHeight;
return {
groupCount: list?.querySelectorAll('.account-hoster-group').length || 0,
listOverflows: Boolean(list && list.scrollHeight > list.clientHeight),
listScrolls: Boolean(list && list.scrollTop > 0),
groupContained: Boolean(group && group.scrollHeight <= group.clientHeight + 1)
};
})()\`);
check('One tall account group overflows through the Accounts list', tallAccountGroupGeometry.groupCount === 1 && tallAccountGroupGeometry.listOverflows && tallAccountGroupGeometry.listScrolls && tallAccountGroupGeometry.groupContained);
await captureVisual('02-accounts-tall-1280x720.png');
const expandedAccountsGeometry = await wc.executeJavaScript(\`(() => {
const hosters = HOSTERS.slice(0, 4);
HOSTERS.forEach(name => { config.hosters[name] = []; });
accountStatuses = {};
hosters.forEach((hoster, index) => {
const account = {
id: 'ui-overflow-account-' + (index + 1),
label: 'Fictional account ' + (index + 1),
enabled: true,
authType: 'login',
username: 'account-' + (index + 1) + '@example.invalid',
password: 'fictional-password-' + (index + 1)
};
config.hosters[hoster] = [account];
accountStatuses[account.id] = { status: index === 0 ? 'error' : 'ok', message: index === 0 ? 'Fictional error' : 'Bereit' };
});
renderAccounts();
document.querySelectorAll('[data-hoster-toggle]').forEach(header => {
if (header.nextElementSibling?.style.display === 'none') header.click();
});
[...document.querySelectorAll('[data-hoster-settings-toggle]')].slice(0, 3).forEach(header => header.click());
const list = document.getElementById('accountsList');
const groups = [...list.querySelectorAll('.account-hoster-group')].filter(group => !group.hidden);
if (list) list.scrollTop = list.scrollHeight;
return {
groupCount: groups.length,
openGroupCount: groups.filter(group => group.querySelector('.account-hoster-group-body')?.style.display !== 'none').length,
openSettingsCount: groups.filter(group => group.querySelector('.account-hoster-settings-body')?.style.display !== 'none').length,
listClientHeight: list?.clientHeight || 0,
listScrollHeight: list?.scrollHeight || 0,
listScrollTop: list?.scrollTop || 0,
bottomReachable: Boolean(list && list.scrollTop + list.clientHeight >= list.scrollHeight - 1),
groupsContained: groups.every(group => group.scrollHeight <= group.clientHeight + 1)
};
})()\`);
console.log('Accounts overflow geometry:', JSON.stringify(expandedAccountsGeometry));
check('Expanded account fixtures render four open hoster groups and three open settings sections', expandedAccountsGeometry.groupCount === 4 && expandedAccountsGeometry.openGroupCount === 4 && expandedAccountsGeometry.openSettingsCount >= 3);
check('Expanded account groups overflow through the Accounts list', expandedAccountsGeometry.listScrollHeight > expandedAccountsGeometry.listClientHeight);
check('Expanded Accounts list accepts a positive scrollTop and reaches its bottom', expandedAccountsGeometry.listScrollTop > 0 && expandedAccountsGeometry.bottomReachable);
check('Expanded account hoster groups do not clip their own content', expandedAccountsGeometry.groupsContained);
await captureVisual('02-accounts-expanded-1280x720.png');
const filteredAccountsGeometry = await wc.executeJavaScript(\`(() => {
document.querySelector('[data-accounts-sidebar-filter="error"]')?.click();
const groups = [...document.querySelectorAll('#accountsList .account-hoster-group')];
const visibleGroups = groups.filter(group => !group.hidden);
const result = {
visibleGroupCount: visibleGroups.length,
hiddenGroupCount: groups.filter(group => group.hidden).length,
groupsContained: visibleGroups.every(group => group.scrollHeight <= group.clientHeight + 1)
};
return result;
})()\`);
check('Filtered account state hides unmatched groups without clipping the visible group', filteredAccountsGeometry.visibleGroupCount === 1 && filteredAccountsGeometry.hiddenGroupCount === 3 && filteredAccountsGeometry.groupsContained);
await captureVisual('02-accounts-filtered-1280x720.png');
await wc.executeJavaScript('document.querySelector("[data-accounts-sidebar-filter=all]")?.click()');
win.setBounds(originalBounds);
const mixedGroupStatus = await wc.executeJavaScript(\`(() => { const mixedGroupStatus = await wc.executeJavaScript(\`(() => {
config.hosters['byse.sx'] = [ config.hosters['byse.sx'] = [
{ id: 'ui-status-ok-1', enabled: true, authType: 'api', apiKey: 'key-one' }, { id: 'ui-status-ok-1', enabled: true, authType: 'api', apiKey: 'key-one' },
@@ -1512,11 +1628,12 @@ setTimeout(async () => {
console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed); console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed);
}; };
if (realAppQuit) { if (realAppQuit) {
app.once('will-quit', () => { app.once('will-quit', event => {
event.preventDefault();
check('Lost restart intent does not relaunch during the later normal quit', relaunchCalls === 0); check('Lost restart intent does not relaunch during the later normal quit', relaunchCalls === 0);
check('Approved update launches its prepared installer exactly once', preparedUpdateMockCalls === 2 && launchedUpdateMockCalls === 1); check('Approved update launches its prepared installer exactly once', preparedUpdateMockCalls === 2 && launchedUpdateMockCalls === 1);
printResults(); printResults();
process.exitCode = failed > 0 ? 1 : 0; app.exit(failed > 0 ? 1 : 0);
}); });
realAppQuit(); realAppQuit();
return; return;