139 lines
9.2 KiB
JavaScript
139 lines
9.2 KiB
JavaScript
// 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 });
|