Files
archy/tests/lifecycle/cloud-upload-resume.cjs
T

139 lines
9.2 KiB
JavaScript
Raw Normal View History

// Real File Browser acceptance: only unique test files are created/deleted.
// CLOUD_URL=http://node CLOUD_COOKIE_FILE=/private/session.json node this-file
const fs = require('node:fs');
const crypto = require('node:crypto');
const { chromium, expect } = require('../../neode-ui/node_modules/@playwright/test');
const base = process.env.CLOUD_URL;
if (!base || !process.env.CLOUD_COOKIE_FILE) throw Error('Set CLOUD_URL and CLOUD_COOKIE_FILE');
(async () => {
const browser = await chromium.launch({ headless: true });
try {
for (const width of [1440, 390]) {
const ctx = await browser.newContext({ viewport: { width, height: 950 }, serviceWorkers: 'block' });
await ctx.addCookies(Object.entries(JSON.parse(fs.readFileSync(process.env.CLOUD_COOKIE_FILE))).map(([name, value]) => ({ name, value, domain: new URL(base).hostname, path: '/' })));
const meta = await ctx.request.get(`${base}/packages/archipelago-companion.json`).then(r => r.json());
await ctx.addInitScript(marker => { localStorage.setItem('neode-auth', 'true'); localStorage.setItem('neode_companion_intro_seen', marker) }, meta.versionCode ? `build:${meta.versionCode}` : `version:${meta.versionName}`);
const page = await ctx.newPage();
const lifecycle = await ctx.newCDPSession(page);
await page.goto(`${base}/dashboard/cloud/files`, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.locator('button[title="Upload file"]').waitFor({ timeout: 60000 });
await page.evaluate(() => {
const app = document.querySelector('#app').__vue_app__;
window.testRouter = app.config.globalProperties.$router;
window.testCloud = Reflect.ownKeys(app._context.provides).map(k => app._context.provides[k]).find(v => v?._s?.has('cloud'))._s.get('cloud');
});
const dest = await page.evaluate(() => window.testCloud.currentPath);
const tag = `archy-resume-${width}-${Date.now()}`;
const names = [`${tag} + 100% ü.txt`, `${tag}-cancel.txt`, `${tag}-never.txt`, `${tag}-empty.txt`];
const bytes = crypto.randomBytes(5 * 1024 * 1024 + 123);
const expected = crypto.createHash('sha256').update(bytes).digest('hex');
const offsets = [];
let partial = false, final = false, renamed = false, posts = 0;
let refreshArmed = false, refreshAttempts = 0;
const authResponses = [];
page.on('response', async response => {
if (!response.url().endsWith('/rpc/v1') || response.request().postDataJSON()?.method !== 'app.filebrowser-token') return;
const body = await response.json().catch(() => ({}));
authResponses.push({ status: response.status(), tokenPresent: !!body.result?.token,
error: body.error ? String(body.error.message || body.error).slice(0, 180) : null });
});
await page.route('**/rpc/v1', async route => {
const body = route.request().postDataJSON();
if (refreshArmed && body?.method === 'app.filebrowser-token') {
refreshAttempts++;
if (refreshAttempts === 1) return route.abort('connectionreset');
}
return route.continue();
});
await page.route('**/api/tus/**', async route => {
const req = route.request();
if (req.method() === 'POST') posts++;
if (req.method() !== 'PATCH') return route.continue();
offsets.push(Number(req.headers()['upload-offset']));
if (!partial) {
partial = true;
const response = await route.fetch({ postData: req.postDataBuffer().subarray(0, 123456) });
if (response.status() !== 204) throw Error('Partial chunk rejected: ' + response.status());
return route.abort('connectionreset');
}
if (!final && offsets[offsets.length - 1] + req.postDataBuffer().length === bytes.length) {
final = true;
const response = await route.fetch();
if (response.status() !== 204) throw Error('Final chunk rejected: ' + response.status());
return route.abort('connectionreset');
}
return route.continue();
});
await page.route('**/api/resources/**', async route => {
if (route.request().method() === 'PATCH' && !renamed) {
renamed = true;
const response = await route.fetch();
if (!response.ok()) throw Error('Rename rejected: ' + response.status());
return route.abort('connectionreset');
}
return route.continue();
});
try {
await page.locator('input[type=file]').setInputFiles({ name: names[0], mimeType: 'application/octet-stream', buffer: bytes });
await page.getByText(`Connection interrupted · resuming ${names[0]}`, { exact: true }).waitFor({ timeout: 30000 });
await ctx.setOffline(true);
refreshArmed = true;
await page.evaluate(() => { document.cookie = 'auth=; path=/; Max-Age=0' });
await lifecycle.send('Page.setWebLifecycleState', { state: 'frozen' });
await new Promise(resolve => setTimeout(resolve, 1200));
await ctx.setOffline(false);
await lifecycle.send('Page.setWebLifecycleState', { state: 'active' });
await page.evaluate(async () => {
const failure = await window.testRouter.push('/dashboard/apps');
if (failure) throw Error('Apps navigation failed: ' + failure.message);
});
await expect(page).toHaveURL(/\/dashboard\/apps$/, { timeout: 15000 });
await expect(page.getByRole('button', { name: 'Cancel upload', exact: true })).toHaveCount(0);
await page.getByText(`Upload complete · ${names[0]}`, { exact: true }).waitFor({ timeout: 90000 });
await page.getByRole('button', { name: 'View upload', exact: false }).click();
await page.getByText(`Complete · ${names[0]}`, { exact: true }).waitFor();
const height = await page.getByRole('button', { name: 'Dismiss upload', exact: true }).locator('..').evaluate(el => el.getBoundingClientRect().height);
if (height !== 44 || posts !== 1 || offsets[1] !== 123456 || !final || !renamed || refreshAttempts < 2) throw Error('Resume/commit/auth-refresh/bar invariant failed');
const actual = await page.evaluate(async ({ dest, name }) => {
const url = await window.testCloud.fetchBlobUrl(dest.replace(/\/$/, '') + '/' + name);
const data = await (await fetch(url)).arrayBuffer(); URL.revokeObjectURL(url);
// crypto.subtle is unavailable on plain LAN HTTP; return base64 to hash in harness.
let text = ''; for (const n of new Uint8Array(data)) text += String.fromCharCode(n);
return btoa(text);
}, { dest, name: names[0] });
if (crypto.createHash('sha256').update(Buffer.from(actual, 'base64')).digest('hex') !== expected) throw Error('Saved bytes mismatch');
await page.getByRole('button', { name: 'Dismiss upload', exact: true }).click();
await page.unroute('**/api/tus/**'); await page.unroute('**/api/resources/**');
let cancelPatch;
const seen = new Promise(resolve => cancelPatch = resolve);
await page.route('**/api/tus/**', async route => {
if (route.request().method() === 'PATCH') { cancelPatch(); return route.abort('connectionreset') }
return route.continue();
});
await page.locator('input[type=file]').setInputFiles(names.slice(1, 3).map(name => ({ name, mimeType: 'text/plain', buffer: Buffer.from('cancel fixture') })));
await seen;
await page.getByRole('button', { name: 'Cancel upload', exact: true }).click();
await page.getByText('Upload stopped · 0/2 saved', { exact: true }).waitFor({ timeout: 15000 });
await page.unroute('**/api/tus/**');
await page.getByRole('button', { name: 'Dismiss upload', exact: true }).click();
await page.locator('input[type=file]').setInputFiles({ name: names[3], mimeType: 'text/plain', buffer: Buffer.alloc(0) });
await page.getByText(`Complete · ${names[3]}`, { exact: true }).waitFor({ timeout: 30000 });
console.log('PASS real upload', width, 'partial-write resume at123456, offline frozen-page return, interrupted JWT refresh, final ACK loss, rename ACK loss, exact SHA256, encoded name, origin-only44px, notification, cancel queue, empty file');
} catch (error) {
console.error('Token refresh diagnostics:', { refreshAttempts, authResponses });
console.error('Upload state at failure:', await page.evaluate(() => ({ route: window.testRouter.currentRoute.value.fullPath, origin: window.testCloud.upload?.originRoute, active: window.testCloud.upload?.active, error: window.testCloud.upload?.error, sent: window.testCloud.upload?.sent, paused: window.testCloud.upload?.paused })));
throw error;
} finally {
await lifecycle.send('Page.setWebLifecycleState', { state: 'active' });
await ctx.setOffline(false);
await page.unrouteAll({ behavior: 'ignoreErrors' });
await page.evaluate(async ({ dest, names }) => {
window.testCloud.cancelUpload();
for (const name of names) { try { await window.testCloud.deleteItem(dest.replace(/\/$/, '') + '/' + name) } catch {} }
}, { dest, names });
await ctx.close();
}
}
} finally { await browser.close() }
})().catch(e => { console.error(e.message); process.exitCode = 1 });