Coalesce concurrent and repeated Doodstream login checks by credential identity, keep the challenged cookie session for OTP verification, and rate-limit explicit code resends. Prefer an existing Doodstream API key during health checks and add regression coverage for imports with duplicate accounts, concurrent checks, expired challenges, and session reuse.
This commit is contained in:
+108
-1
@@ -1,3 +1,5 @@
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
// Decides which credential an upload task should use for a given hoster.
|
||||
// Extracted from main.js buildTaskFromAccount so the routing can be unit-tested
|
||||
// without Electron.
|
||||
@@ -33,4 +35,109 @@ function selectUploadAuth(hoster, account) {
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = { selectUploadAuth };
|
||||
function createDoodstreamOtpCoordinator(options = {}) {
|
||||
if (typeof options.createUploader !== 'function') throw new TypeError('createUploader is required');
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const challengeTtlMs = Number.isFinite(Number(options.challengeTtlMs)) ? Math.max(1000, Number(options.challengeTtlMs)) : 10 * 60 * 1000;
|
||||
const resendCooldownMs = Number.isFinite(Number(options.resendCooldownMs)) ? Math.max(1000, Number(options.resendCooldownMs)) : 60 * 1000;
|
||||
const maxEntries = Number.isFinite(Number(options.maxEntries)) ? Math.max(1, Math.floor(Number(options.maxEntries))) : 1000;
|
||||
const states = new Map();
|
||||
|
||||
function credentialKey(username, password) {
|
||||
return createHash('sha256')
|
||||
.update(String(username || ''))
|
||||
.update('\0')
|
||||
.update(String(password || ''))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function storeState(key, state) {
|
||||
states.delete(key);
|
||||
states.set(key, state);
|
||||
while (states.size > maxEntries) states.delete(states.keys().next().value);
|
||||
}
|
||||
|
||||
function activeState(key) {
|
||||
const state = states.get(key);
|
||||
if (!state || state.inFlight) return state || null;
|
||||
if (state.expiresAt > now()) return state;
|
||||
states.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function check(input = {}) {
|
||||
const username = String(input.username || '');
|
||||
const password = String(input.password || '');
|
||||
const otp = String(input.otp || '').trim();
|
||||
const key = credentialKey(username, password);
|
||||
const existing = activeState(key);
|
||||
if (existing?.inFlight) return existing.inFlight;
|
||||
if (otp && !existing?.pending) {
|
||||
return {
|
||||
status: 'otp_required',
|
||||
message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.'
|
||||
};
|
||||
}
|
||||
if (!otp && existing?.pending && input.requestNewChallenge !== true) return existing.result;
|
||||
if (!otp && existing?.pending && now() - existing.requestedAt < resendCooldownMs) return existing.result;
|
||||
|
||||
const uploader = otp ? existing.uploader : options.createUploader();
|
||||
const requestedAt = otp ? existing.requestedAt : now();
|
||||
const operationId = Symbol('doodstream-otp-check');
|
||||
const operation = (async () => {
|
||||
try {
|
||||
await uploader.login(username, password, otp || undefined);
|
||||
if (states.get(key)?.operationId === operationId) states.delete(key);
|
||||
return { status: 'ok', message: 'Login ok, Upload-Seite bereit' };
|
||||
} catch (error) {
|
||||
if (error?.otpRequired === true) {
|
||||
const result = { status: 'otp_required', message: error.message || 'OTP erforderlich' };
|
||||
if (states.get(key)?.operationId === operationId) {
|
||||
storeState(key, {
|
||||
operationId,
|
||||
uploader,
|
||||
pending: true,
|
||||
requestedAt: otp ? existing.requestedAt : requestedAt,
|
||||
expiresAt: now() + challengeTtlMs,
|
||||
result,
|
||||
inFlight: null
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (otp && existing?.pending) {
|
||||
const result = {
|
||||
status: 'otp_required',
|
||||
message: error?.message || 'OTP konnte nicht bestätigt werden'
|
||||
};
|
||||
if (states.get(key)?.operationId === operationId) {
|
||||
storeState(key, {
|
||||
...existing,
|
||||
operationId,
|
||||
uploader,
|
||||
result,
|
||||
inFlight: null
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (states.get(key)?.operationId === operationId) states.delete(key);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
storeState(key, {
|
||||
operationId,
|
||||
uploader,
|
||||
pending: existing?.pending === true,
|
||||
requestedAt,
|
||||
expiresAt: existing?.expiresAt || (requestedAt + challengeTtlMs),
|
||||
result: existing?.result || null,
|
||||
inFlight: operation
|
||||
});
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { check };
|
||||
}
|
||||
|
||||
module.exports = { createDoodstreamOtpCoordinator, selectUploadAuth };
|
||||
|
||||
@@ -122,9 +122,10 @@ class DoodstreamUploader {
|
||||
* Login to DoodStream via web form
|
||||
*/
|
||||
async login(username, password, otp) {
|
||||
// GET homepage first to collect cookies
|
||||
const homeRes = await this._fetch(BASE_URL);
|
||||
await homeRes.text();
|
||||
if (!otp || this.cookies.size === 0) {
|
||||
const homeRes = await this._fetch(BASE_URL);
|
||||
await homeRes.text();
|
||||
}
|
||||
|
||||
// POST login via AJAX (op in body, XHR header required for JSON response)
|
||||
const loginData = new URLSearchParams({
|
||||
|
||||
@@ -14,7 +14,7 @@ const { HOSTER_CONFIGS } = require('./lib/hosters');
|
||||
const VidmolyUploader = require('./lib/vidmoly-upload');
|
||||
const VoeUploader = require('./lib/voe-upload');
|
||||
const DoodstreamUploader = require('./lib/doodstream-upload');
|
||||
const { selectUploadAuth } = require('./lib/account-auth');
|
||||
const { createDoodstreamOtpCoordinator, selectUploadAuth } = require('./lib/account-auth');
|
||||
const { createAccountCooldownController, createAccountPicker } = require('./lib/account-rotation');
|
||||
const ClouddropUploader = require('./lib/clouddrop-upload');
|
||||
const { checkForUpdate, prepareUpdate, launchPreparedUpdate, abortUpdate, createUpdateAnnouncementState } = require('./lib/updater');
|
||||
@@ -413,6 +413,9 @@ let captureWindow = null;
|
||||
let captureWindowReady = false;
|
||||
let signalingQueue = [];
|
||||
const HEALTH_CHECK_TIMEOUT = 25000;
|
||||
const doodstreamHealthCoordinator = createDoodstreamOtpCoordinator({
|
||||
createUploader: () => new DoodstreamUploader()
|
||||
});
|
||||
|
||||
// --- Debug logging (writes to upload-debug.log next to the app) ---
|
||||
function getDebugLogPath() {
|
||||
@@ -1268,33 +1271,19 @@ async function registerAutomationCompletionJobs(manager, jobs) {
|
||||
await Promise.all(Array.from({ length: Math.min(16, candidates.length) }, worker));
|
||||
}
|
||||
|
||||
async function checkDoodstreamHealth(hosterConfig, otp) {
|
||||
const username = hosterConfig && hosterConfig.username
|
||||
? String(hosterConfig.username).trim()
|
||||
: '';
|
||||
const password = hosterConfig && hosterConfig.password
|
||||
? String(hosterConfig.password).trim()
|
||||
: '';
|
||||
async function checkDoodstreamHealth(hosterConfig, otp, options = {}) {
|
||||
const auth = selectUploadAuth('doodstream.com', hosterConfig);
|
||||
const apiKey = auth.apiKey ? String(auth.apiKey).trim() : '';
|
||||
|
||||
// Login-based check (preferred)
|
||||
if (username && password) {
|
||||
const uploader = new DoodstreamUploader();
|
||||
try {
|
||||
await uploader.login(username, password, otp || undefined);
|
||||
} catch (err) {
|
||||
if (err.otpRequired) {
|
||||
return { status: 'otp_required', message: err.message || 'OTP erforderlich' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { status: 'ok', message: 'Login ok, Upload-Seite bereit' };
|
||||
if (!apiKey && auth.username && auth.password) {
|
||||
return doodstreamHealthCoordinator.check({
|
||||
username: String(auth.username).trim(),
|
||||
password: String(auth.password).trim(),
|
||||
otp,
|
||||
requestNewChallenge: options.requestNewOtp === true
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to API key check
|
||||
const apiKey = hosterConfig && hosterConfig.apiKey
|
||||
? String(hosterConfig.apiKey).trim()
|
||||
: '';
|
||||
|
||||
if (!apiKey) {
|
||||
return { status: 'error', message: 'Login oder API Key fehlt' };
|
||||
}
|
||||
@@ -1517,7 +1506,7 @@ async function runHosterHealthCheck(config, requestedChecks) {
|
||||
checks = cleaned;
|
||||
}
|
||||
|
||||
const runOne = async ({ hoster, accountId, otp, _invalid }) => {
|
||||
const runOne = async ({ hoster, accountId, otp, requestNewOtp, _invalid }) => {
|
||||
if (_invalid) {
|
||||
return { hoster, accountId, status: 'error', message: 'Account-ID fehlt im Check-Payload' };
|
||||
}
|
||||
@@ -1527,7 +1516,9 @@ async function runHosterHealthCheck(config, requestedChecks) {
|
||||
const accounts = config.hosters[hoster];
|
||||
const hosterConfig = Array.isArray(accounts) ? accounts.find(a => a.id === accountId) : null;
|
||||
try {
|
||||
const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '');
|
||||
const result = await _dispatchHealthCheck(hoster, hosterConfig, otp || '', {
|
||||
requestNewOtp: requestNewOtp === true
|
||||
});
|
||||
return { hoster, accountId, ...result };
|
||||
} catch (err) {
|
||||
return { hoster, accountId, status: 'error', message: err && err.message ? err.message : 'Health-Check fehlgeschlagen' };
|
||||
@@ -2003,18 +1994,20 @@ ipcMain.handle('validate-credentials', async (_event, payload) => {
|
||||
enabled: true
|
||||
};
|
||||
try {
|
||||
return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || '');
|
||||
return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || '', {
|
||||
requestNewOtp: payload.requestNewOtp === true
|
||||
});
|
||||
} catch (err) {
|
||||
return { status: 'error', message: err && err.message ? err.message : 'Validierung fehlgeschlagen' };
|
||||
}
|
||||
});
|
||||
|
||||
async function _dispatchHealthCheck(hoster, hosterConfig, otp) {
|
||||
async function _dispatchHealthCheck(hoster, hosterConfig, otp, options = {}) {
|
||||
// Mirrors the per-hoster switch in runHosterHealthCheck so both code paths
|
||||
// (batch check by accountId and ephemeral validate) go through identical
|
||||
// checkers + timeout wrappers and surface identical result shapes.
|
||||
if (hoster === 'doodstream.com') {
|
||||
return withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
|
||||
return withTimeout(checkDoodstreamHealth(hosterConfig, otp, options), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
|
||||
}
|
||||
if (hoster === 'vidmoly.me') {
|
||||
return withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
|
||||
|
||||
+4
-2
@@ -7355,6 +7355,7 @@ function _buildAccountCardHtml(name, account, idx) {
|
||||
: '';
|
||||
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
|
||||
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
|
||||
const checkLabel = statusPresentation.requiresOtp ? 'Neuen Code anfordern' : 'Prüfen';
|
||||
|
||||
const sessionPauseKey = `${name}:${account.id}`;
|
||||
const sessionPause = _sessionFailedAccountStates.get(sessionPauseKey)
|
||||
@@ -7384,7 +7385,7 @@ function _buildAccountCardHtml(name, account, idx) {
|
||||
</span>
|
||||
<div class="account-card-actions">
|
||||
<button class="btn btn-xs btn-secondary" data-account-toggle="${account.id}">${toggleLabel}</button>
|
||||
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>Prüfen</button>
|
||||
<button class="btn btn-xs btn-secondary" data-account-check="${account.id}" ${isDisabled ? 'disabled' : ''}>${checkLabel}</button>
|
||||
<button class="btn btn-xs btn-secondary" data-account-edit="${account.id}">Bearbeiten</button>
|
||||
<button class="btn btn-xs btn-danger" data-account-delete="${account.id}">Löschen</button>
|
||||
</div>
|
||||
@@ -7906,13 +7907,14 @@ async function checkSingleAccount(accountId) {
|
||||
if (!accountId || healthCheckRunning) return;
|
||||
const found = findAccountById(accountId);
|
||||
if (!found) return;
|
||||
const requestNewOtp = accountStatuses[accountId]?.status === 'otp_required';
|
||||
const generation = _nextAccountStatusGeneration(accountId);
|
||||
healthCheckRunning = true;
|
||||
accountStatuses[accountId] = { ...(accountStatuses[accountId] || {}), status: 'checking', message: '' };
|
||||
updateAccountCard(accountId);
|
||||
let nextStatus = null;
|
||||
try {
|
||||
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
|
||||
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, requestNewOtp }] });
|
||||
const rows = result && Array.isArray(result.results) ? result.results : [];
|
||||
const row = rows.find(r => r.accountId === accountId);
|
||||
const checkedAt = result?.checkedAt || new Date().toISOString();
|
||||
|
||||
@@ -704,6 +704,8 @@
|
||||
['Updateprüfung fehlgeschlagen', 'Update check failed'],
|
||||
['Upload läuft...', 'Uploading...'],
|
||||
['Upload-Log', 'Upload log'],
|
||||
['Neuen Code anfordern', 'Request new code'],
|
||||
['OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.', 'The OTP request has expired. Request a new code.'],
|
||||
['Automatik-Abschlussnachweis konnte nicht gespeichert werden', 'Automation completion evidence could not be saved'],
|
||||
['Lokale Speicherung', 'Local persistence'],
|
||||
['Automatik-Abschlussdatei ist ungültig', 'Automation completion file is invalid'],
|
||||
|
||||
@@ -10,6 +10,7 @@ const publicActionsDir = `.${['git', 'hub'].join('')}`;
|
||||
const privateActionsDir = `.${['gi', 'tea'].join('')}`;
|
||||
const sourceFiles = [
|
||||
'.gitignore',
|
||||
'PROJECT_MEMORY.md',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'assets/app_icon.ico',
|
||||
|
||||
+118
-1
@@ -1,6 +1,6 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { selectUploadAuth } = require('../lib/account-auth');
|
||||
const { createDoodstreamOtpCoordinator, selectUploadAuth } = require('../lib/account-auth');
|
||||
|
||||
test('doodstream prefers the API key even when username/password are also set', () => {
|
||||
const auth = selectUploadAuth('doodstream.com', {
|
||||
@@ -42,3 +42,120 @@ test('null / non-object account does not throw', () => {
|
||||
assert.deepEqual(selectUploadAuth('doodstream.com', null), {});
|
||||
assert.deepEqual(selectUploadAuth('doodstream.com', undefined), {});
|
||||
});
|
||||
|
||||
function otpRequired(message = 'OTP erforderlich') {
|
||||
const error = new Error(message);
|
||||
error.otpRequired = true;
|
||||
return error;
|
||||
}
|
||||
|
||||
test('concurrent and repeated Doodstream checks request only one OTP', async () => {
|
||||
let loginCalls = 0;
|
||||
let releaseLogin;
|
||||
const coordinator = createDoodstreamOtpCoordinator({
|
||||
createUploader: () => ({
|
||||
login: () => new Promise((resolve, reject) => {
|
||||
loginCalls++;
|
||||
releaseLogin = () => reject(otpRequired('OTP gesendet'));
|
||||
})
|
||||
})
|
||||
});
|
||||
const first = coordinator.check({ username: 'user', password: 'secret' });
|
||||
const second = coordinator.check({ username: 'user', password: 'secret' });
|
||||
assert.equal(loginCalls, 1);
|
||||
releaseLogin();
|
||||
assert.deepEqual(await first, { status: 'otp_required', message: 'OTP gesendet' });
|
||||
assert.deepEqual(await second, { status: 'otp_required', message: 'OTP gesendet' });
|
||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), { status: 'otp_required', message: 'OTP gesendet' });
|
||||
assert.equal(loginCalls, 1);
|
||||
});
|
||||
|
||||
test('Doodstream OTP verification reuses the challenged uploader session', async () => {
|
||||
let created = 0;
|
||||
const calls = [];
|
||||
const coordinator = createDoodstreamOtpCoordinator({
|
||||
createUploader: () => {
|
||||
created++;
|
||||
return {
|
||||
async login(_username, _password, otp) {
|
||||
calls.push(otp || '');
|
||||
if (!otp) throw otpRequired('OTP gesendet');
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
assert.equal((await coordinator.check({ username: 'user', password: 'secret' })).status, 'otp_required');
|
||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
|
||||
status: 'ok',
|
||||
message: 'Login ok, Upload-Seite bereit'
|
||||
});
|
||||
assert.equal(created, 1);
|
||||
assert.deepEqual(calls, ['', '123456']);
|
||||
});
|
||||
|
||||
test('a rejected OTP remains pending without requesting another code', async () => {
|
||||
let created = 0;
|
||||
let calls = 0;
|
||||
const coordinator = createDoodstreamOtpCoordinator({
|
||||
createUploader: () => {
|
||||
created++;
|
||||
return {
|
||||
async login(_username, _password, otp) {
|
||||
calls++;
|
||||
if (!otp) throw otpRequired('OTP gesendet');
|
||||
throw new Error('Code ungültig');
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await coordinator.check({ username: 'user', password: 'secret' });
|
||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '000000' }), {
|
||||
status: 'otp_required',
|
||||
message: 'Code ungültig'
|
||||
});
|
||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret' }), {
|
||||
status: 'otp_required',
|
||||
message: 'Code ungültig'
|
||||
});
|
||||
assert.equal(created, 1);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('explicit OTP resend is rate-limited and starts one fresh session', async () => {
|
||||
let currentTime = 1000;
|
||||
let created = 0;
|
||||
const coordinator = createDoodstreamOtpCoordinator({
|
||||
now: () => currentTime,
|
||||
resendCooldownMs: 60000,
|
||||
createUploader: () => {
|
||||
created++;
|
||||
return { login: async () => { throw otpRequired('OTP gesendet'); } };
|
||||
}
|
||||
});
|
||||
await coordinator.check({ username: 'user', password: 'secret' });
|
||||
await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true });
|
||||
assert.equal(created, 1);
|
||||
currentTime += 60000;
|
||||
await coordinator.check({ username: 'user', password: 'secret', requestNewChallenge: true });
|
||||
assert.equal(created, 2);
|
||||
});
|
||||
|
||||
test('expired OTP submission cannot create a replacement challenge implicitly', async () => {
|
||||
let currentTime = 1000;
|
||||
let created = 0;
|
||||
const coordinator = createDoodstreamOtpCoordinator({
|
||||
now: () => currentTime,
|
||||
challengeTtlMs: 1000,
|
||||
createUploader: () => {
|
||||
created++;
|
||||
return { login: async () => { throw otpRequired('OTP gesendet'); } };
|
||||
}
|
||||
});
|
||||
await coordinator.check({ username: 'user', password: 'secret' });
|
||||
currentTime += 1001;
|
||||
assert.deepEqual(await coordinator.check({ username: 'user', password: 'secret', otp: '123456' }), {
|
||||
status: 'otp_required',
|
||||
message: 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.'
|
||||
});
|
||||
assert.equal(created, 1);
|
||||
});
|
||||
|
||||
@@ -161,6 +161,43 @@ function fakeRes(body, { status = 200, ctype = 'text/html' } = {}) {
|
||||
return { status, headers: { get: (h) => (h.toLowerCase() === 'content-type' ? ctype : null) }, text: async () => body };
|
||||
}
|
||||
|
||||
test('OTP verification keeps the challenged cookie session without another bootstrap request', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let bootstrapCalls = 0;
|
||||
let loginCalls = 0;
|
||||
up._fetch = async () => {
|
||||
bootstrapCalls++;
|
||||
return fakeRes(bootstrapCalls === 1 ? 'ok' : '<input type="hidden" name="sess_id" value="SESSION456">');
|
||||
};
|
||||
globalThis.fetch = async (_url, options) => {
|
||||
loginCalls++;
|
||||
if (loginCalls === 1) {
|
||||
assert.equal(options.headers.Cookie, undefined);
|
||||
return {
|
||||
status: 200,
|
||||
headers: { getSetCookie: () => ['otp_session=SESSION123; Path=/'], get: () => null },
|
||||
text: async () => JSON.stringify({ status: 'fail', message: 'OTP required' })
|
||||
};
|
||||
}
|
||||
assert.equal(options.headers.Cookie, 'otp_session=SESSION123');
|
||||
assert.match(options.body, /loginotp=123456/u);
|
||||
return {
|
||||
status: 302,
|
||||
headers: { getSetCookie: () => [], get: () => '/dashboard' },
|
||||
text: async () => ''
|
||||
};
|
||||
};
|
||||
try {
|
||||
await assert.rejects(() => up.login('user', 'secret'), error => error.otpRequired === true);
|
||||
await up.login('user', 'secret', '123456');
|
||||
assert.equal(bootstrapCalls, 2);
|
||||
assert.equal(loginCalls, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('getUploadServer: returns JSON result when present', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
|
||||
@@ -51,6 +51,8 @@ test('translates managed online backup controls in both directions', () => {
|
||||
test('translates the account check timestamp label', () => {
|
||||
assert.equal(translateText('geprüft', 'en'), 'checked');
|
||||
assert.equal(translateText('checked', 'de'), 'geprüft');
|
||||
assert.equal(translateText('Neuen Code anfordern', 'en'), 'Request new code');
|
||||
assert.equal(translateText('The OTP request has expired. Request a new code.', 'de'), 'OTP-Anfrage ist abgelaufen. Bitte einen neuen Code anfordern.');
|
||||
});
|
||||
|
||||
test('translates settings search result labels in both directions', () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ test('public release verifier accepts only the exact source manifest and target
|
||||
assert.equal(baseline.status, 0, baseline.stderr);
|
||||
assert.equal(
|
||||
baseline.stdout,
|
||||
`public-release-source-ok files=159 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
||||
`public-release-source-ok files=160 denied-paths=0 internal-terms=0 version=${currentVersion} scripts=8 build-files=7 layout=exact screenshot=deferred\n`
|
||||
);
|
||||
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||
|
||||
Reference in New Issue
Block a user