mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 21:40:50 +00:00
656 lines
29 KiB
JavaScript
656 lines
29 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdtemp } from 'node:fs/promises';
|
|
import { createRequire } from 'node:module';
|
|
import { createServer as createPortProbe } from 'node:net';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createServer } from 'vite';
|
|
import vue from '@vitejs/plugin-vue';
|
|
|
|
// Real SFCs and browser compression; only transport/session and setup observation are isolated.
|
|
const adminRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
const localRequire = createRequire(import.meta.url);
|
|
const runtimeRequire = createRequire(
|
|
path.join(
|
|
process.env.CRMEB_BROWSER_TEST_RUNTIME ||
|
|
path.join(homedir(), '.cache/codex-runtimes/codex-primary-runtime/dependencies/node'),
|
|
'package.json',
|
|
),
|
|
);
|
|
function testDependency(name) {
|
|
try {
|
|
return localRequire(name);
|
|
} catch (error) {
|
|
if (error.code !== 'MODULE_NOT_FOUND') throw error;
|
|
return runtimeRequire(name);
|
|
}
|
|
}
|
|
const { chromium } = testDependency('playwright');
|
|
const { PNG } = testDependency('pngjs');
|
|
const pickerPath = path.join(adminRoot, 'src/components/uploadImg/index.vue');
|
|
const phonePath = path.join(adminRoot, 'src/pages/app/upload/index.vue');
|
|
const scanToken = 'b'.repeat(64);
|
|
const labels = {
|
|
confirm: '\u786e \u5b9a',
|
|
cancel: '\u53d6 \u6d88',
|
|
scan: '\u626b\u7801\u4e0a\u4f20',
|
|
refresh: '\u5237\u65b0\u56fe\u5e93',
|
|
upload: '\u786e\u8ba4\u4e0a\u4f20',
|
|
again: '\u7ee7\u7eed\u4e0a\u4f20',
|
|
success: '\u56fe\u7247\u4e0a\u4f20\u6210\u529f',
|
|
};
|
|
|
|
function makePng(width, height, noisy) {
|
|
const png = new PNG({ width, height });
|
|
let seed = 19571;
|
|
for (let i = 0; i < png.data.length; i += 4) {
|
|
for (let channel = 0; channel < 3; channel++) {
|
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
|
png.data[i + channel] = noisy ? seed >>> 24 : 40 + ((i / 4 + channel * 63) % 180);
|
|
}
|
|
png.data[i + 3] = 255;
|
|
}
|
|
return PNG.sync.write(png);
|
|
}
|
|
const bigPng = makePng(900, 900, true);
|
|
assert(bigPng.length >= 2097152 && bigPng.length < 3000000, 'Real PNG must exercise the large-file path');
|
|
const smallPng = makePng(48, 48, false);
|
|
const validFiles = [
|
|
{ name: 'large-fixture.png', mimeType: 'image/png', buffer: bigPng },
|
|
{ name: 'small-fixture.png', mimeType: 'image/png', buffer: smallPng },
|
|
];
|
|
const invalidFile = { name: 'broken-fixture.png', mimeType: 'image/png', buffer: Buffer.alloc(2097152, 120) };
|
|
|
|
async function freeLoopbackPort() {
|
|
const probe = createPortProbe();
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
probe.once('error', reject);
|
|
probe.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
return probe.address().port;
|
|
} finally {
|
|
if (probe.listening)
|
|
await new Promise((resolve, reject) => probe.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
const artifacts = await mkdtemp(path.join(tmpdir(), 'crmeb-storage-upload-browser-'));
|
|
const requests = [];
|
|
const screenshots = [];
|
|
const issues = [];
|
|
let checks = 0;
|
|
let holdUploads = false;
|
|
const pendingUploads = [];
|
|
let origin;
|
|
let server;
|
|
let browser;
|
|
let activePage;
|
|
const scanRows = [1, 2, 3].map((att_id) => ({ att_id, att_dir: '/__storage_upload_bitmap.png' }));
|
|
const virtualIds = {
|
|
'virtual:storage-upload-entry': '\0storage-upload-entry',
|
|
'virtual:storage-upload-request': '\0storage-upload-request',
|
|
'virtual:storage-upload-setting': '\0storage-upload-setting',
|
|
'virtual:storage-upload-util': '\0storage-upload-util',
|
|
};
|
|
const requestModule = `
|
|
export default async function request(config) {
|
|
const url = new URL('/__storage_upload_api/' + config.url, location.origin);
|
|
for (const [key, value] of Object.entries(config.params || {})) {
|
|
if (value !== undefined && value !== null) url.searchParams.set(key, value);
|
|
}
|
|
const response = await fetch(url, {
|
|
method: config.method.toUpperCase(),
|
|
credentials: 'omit',
|
|
headers: config.file ? undefined : { 'Content-Type': 'application/json' },
|
|
body: config.data === undefined ? undefined : config.file ? config.data : JSON.stringify(config.data),
|
|
});
|
|
const result = await response.json();
|
|
if (!response.ok || result.status !== 200) throw result;
|
|
return result;
|
|
}
|
|
`;
|
|
const entryModule = `
|
|
import { createApp, h } from 'vue';
|
|
import { createRouter, createMemoryHistory } from 'vue-router';
|
|
import ElementPlus from 'element-plus';
|
|
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
|
import * as icons from '@element-plus/icons-vue';
|
|
import 'element-plus/dist/index.css';
|
|
import '@/theme/index.scss';
|
|
import '@/styles/index.scss';
|
|
import '@/styles/el-icon-compat.css';
|
|
import UploadPicker from '@/components/uploadImg/index.vue';
|
|
import PhoneUpload from '@/pages/app/upload/index.vue';
|
|
import dbClick from '@/directive/module/dbClick.js';
|
|
const phone = new URLSearchParams(location.search).get('page') === 'phone';
|
|
const router = createRouter({
|
|
history: createMemoryHistory(),
|
|
routes: [{ path: '/', component: { render: () => null } }],
|
|
});
|
|
window.__uploadSuccesses = 0;
|
|
const app = createApp({
|
|
render: () => h('main', { class: 'storage-upload-workspace' }, h(phone ? PhoneUpload : UploadPicker, {
|
|
categoryId: 11,
|
|
categoryList: [{ id: 11, title: 'Fixture images' }],
|
|
onUploadSuccess: () => window.__uploadSuccesses++,
|
|
})),
|
|
});
|
|
app.use(router);
|
|
app.use(ElementPlus, { locale: zhCn });
|
|
for (const [name, component] of Object.entries(icons)) app.component(name, component);
|
|
app.directive('db-click', dbClick);
|
|
await router.push('/?pid=11&token=${scanToken}');
|
|
await router.isReady();
|
|
app.mount('#app');
|
|
`;
|
|
const html = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Isolated real upload browser test</title><style>html,body,#app{min-height:100%;margin:0}.storage-upload-workspace{min-height:100vh;background:var(--prev-bg-main-color)}</style></head><body><div id="app"></div><script type="module">import 'virtual:storage-upload-entry';</script></body></html>`;
|
|
function fixturePlugin() {
|
|
return {
|
|
name: 'isolated-real-upload-browser-fixtures',
|
|
enforce: 'pre',
|
|
resolveId(id) {
|
|
return virtualIds[id];
|
|
},
|
|
load(id) {
|
|
if (id === virtualIds['virtual:storage-upload-entry']) return entryModule;
|
|
if (id === virtualIds['virtual:storage-upload-request']) return requestModule;
|
|
if (id === virtualIds['virtual:storage-upload-setting'])
|
|
return "export default { apiBaseURL: '/__storage_upload_api' };";
|
|
if (id === virtualIds['virtual:storage-upload-util'])
|
|
return "export const getCookies = () => 'isolated-noncredential-fixture';";
|
|
},
|
|
transform(code, id) {
|
|
const filename = id.split('?')[0];
|
|
let observation;
|
|
if (filename === pickerPath) {
|
|
observation = `
|
|
window.__storageUploadHandle = {
|
|
open: () => { uploadModal.value = true; },
|
|
state: () => ({
|
|
preparing: preparing.value, loading: loading.value, open: uploadModal.value, type: ruleForm.type,
|
|
files: ruleForm.imgList.map(item => ({ id: item.att_id, name: item.raw?.name, type: item.raw?.type, size: item.raw?.size })),
|
|
}),
|
|
submit: submitUpload, poll: scanUploadGetFn,
|
|
};
|
|
`;
|
|
} else if (filename === phonePath) {
|
|
observation = `
|
|
window.__storageUploadHandle = {
|
|
state: () => ({
|
|
preparing: preparing.value, loading: loading.value, uploading: uploading.value, total: allSize.value,
|
|
files: imgList.value.map(item => ({ name: item.raw?.name, type: item.raw?.type, size: item.raw?.size })),
|
|
}),
|
|
submit: submitUpload,
|
|
};
|
|
`;
|
|
}
|
|
if (observation && !id.includes('?')) return code.replace('</script>', observation + '</script>');
|
|
},
|
|
configureServer(viteServer) {
|
|
viteServer.middlewares.use(async (req, res, next) => {
|
|
const url = new URL(req.url, 'http://127.0.0.1');
|
|
if (url.pathname === '/') {
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.end(await viteServer.transformIndexHtml('/', html));
|
|
return;
|
|
}
|
|
if (url.pathname === '/favicon.ico') {
|
|
res.statusCode = 204;
|
|
res.end();
|
|
return;
|
|
}
|
|
if (url.pathname === '/__storage_upload_bitmap.png') {
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.end(smallPng);
|
|
return;
|
|
}
|
|
if (!url.pathname.startsWith('/__storage_upload_api/')) return next();
|
|
try {
|
|
for (const header of ['cookie', 'authorization', 'authori-zation'])
|
|
assert.equal(req.headers[header], undefined, 'Fixture request must not carry session credentials');
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
const body = Buffer.concat(chunks);
|
|
const api = url.pathname.slice('/__storage_upload_api/'.length);
|
|
let data;
|
|
if ((req.headers['content-type'] || '').startsWith('multipart/form-data')) {
|
|
const form = await new Request(url, {
|
|
method: req.method,
|
|
headers: { 'Content-Type': req.headers['content-type'] },
|
|
body,
|
|
}).formData();
|
|
data = Object.fromEntries(form);
|
|
assert(data.file instanceof Blob, 'Actual upload API must receive a browser file');
|
|
const buffer = Buffer.from(await data.file.arrayBuffer());
|
|
data.file = { name: data.file.name, type: data.file.type, size: buffer.length, buffer };
|
|
} else if (body.length) {
|
|
data = JSON.parse(body.toString('utf8'));
|
|
}
|
|
requests.push({ api, method: req.method, data, params: Object.fromEntries(url.searchParams) });
|
|
let response;
|
|
if (req.method === 'GET' && api === 'file/scan_upload/qrcode') {
|
|
response = { url: `${origin}/?page=phone&pid=11&token=${scanToken}` };
|
|
} else if (req.method === 'GET' && api === `file/scan_upload/image/${scanToken}`) {
|
|
response = scanRows.map((row) => ({ ...row }));
|
|
} else if (req.method === 'DELETE' && api === 'file/scan_upload/qrcode') {
|
|
response = [];
|
|
} else if (req.method === 'POST' && ['file/upload', 'image/scan_upload'].includes(api)) {
|
|
assert.equal(data.pid, '11');
|
|
if (api === 'image/scan_upload') assert.equal(data.uploadToken, scanToken);
|
|
if (holdUploads) await new Promise((resolve) => pendingUploads.push(resolve));
|
|
response = { att_id: requests.length, att_dir: '/__storage_upload_bitmap.png' };
|
|
} else if (req.method === 'PUT' && api === 'file/file/do_move') {
|
|
assert.equal(data.pid, 11);
|
|
response = [];
|
|
} else {
|
|
throw new Error('Unsupported isolated fixture API: ' + api);
|
|
}
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ status: 200, msg: 'Fixture success', data: response }));
|
|
} catch (error) {
|
|
issues.push(error.message);
|
|
res.statusCode = 500;
|
|
res.setHeader('Content-Type', 'application/json');
|
|
res.end(JSON.stringify({ status: 500, msg: 'Isolated fixture failure', data: [] }));
|
|
}
|
|
});
|
|
},
|
|
};
|
|
}
|
|
function releaseUploads() {
|
|
holdUploads = false;
|
|
for (const resolve of pendingUploads.splice(0)) resolve();
|
|
}
|
|
function uploadRequests(start, api) {
|
|
return requests.slice(start).filter((request) => request.api === api && request.method === 'POST');
|
|
}
|
|
function verifyUploadedFiles(start, api, name) {
|
|
const uploads = uploadRequests(start, api);
|
|
assert.equal(uploads.length, 2, `${name}: both selected files must upload exactly once`);
|
|
const large = uploads.find((request) => request.data.file.name === 'large-fixture.jpg')?.data.file;
|
|
assert(large, `${name}: large PNG must use a .jpg filename`);
|
|
assert.equal(large.type, 'image/jpeg');
|
|
assert(large.size > 1000 && large.size < bigPng.length, `${name}: JPEG encoding must contain compressed pixels`);
|
|
assert.equal(large.buffer.subarray(0, 3).toString('hex'), 'ffd8ff');
|
|
assert.equal(large.buffer.subarray(-2).toString('hex'), 'ffd9');
|
|
const small = uploads.find((request) => request.data.file.name === 'small-fixture.png')?.data.file;
|
|
assert(small, `${name}: small original PNG must remain selected`);
|
|
assert.equal(small.type, 'image/png');
|
|
assert.deepEqual(small.buffer, smallPng);
|
|
checks++;
|
|
}
|
|
async function screenshot(page, name) {
|
|
const filename = path.join(artifacts, name + '.png');
|
|
const buffer = await page.screenshot({ path: filename, fullPage: false, animations: 'disabled' });
|
|
screenshots.push(filename);
|
|
const png = PNG.sync.read(buffer);
|
|
const colors = new Set();
|
|
let ink = 0;
|
|
for (let offset = 0; offset < png.data.length; offset += 16) {
|
|
const [r, g, b] = png.data.subarray(offset, offset + 3);
|
|
colors.add(`${r >> 4},${g >> 4},${b >> 4}`);
|
|
if (Math.min(r, g, b) < 210) ink++;
|
|
}
|
|
assert(colors.size > 12 && ink > 200, `${name}: screenshot must contain rendered bitmap/UI pixels`);
|
|
}
|
|
async function checkLayout(page, name, selector) {
|
|
const result = await page.locator(selector).evaluate((scope) => {
|
|
const problems = [];
|
|
const bounds = scope.getBoundingClientRect();
|
|
const visible = (element) => {
|
|
const r = element.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0 && getComputedStyle(element).visibility !== 'hidden';
|
|
};
|
|
const overlap = (a, b) =>
|
|
Math.min(a.right, b.right) - Math.max(a.left, b.left) > 2 &&
|
|
Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) > 2;
|
|
if (bounds.left < -2 || bounds.right > innerWidth + 2)
|
|
problems.push(`frame exceeds viewport: x=${Math.round(bounds.left)}..${Math.round(bounds.right)}`);
|
|
for (const element of scope.querySelectorAll('.el-cascader, .el-upload, .code, .footer .btn, .el-button')) {
|
|
if (!visible(element)) continue;
|
|
const r = element.getBoundingClientRect();
|
|
if (r.left < bounds.left - 2 || r.right > bounds.right + 2)
|
|
problems.push(`control exceeds frame: ${element.className}, x=${Math.round(r.left)}..${Math.round(r.right)}`);
|
|
}
|
|
for (const item of scope.querySelectorAll('.el-form-item')) {
|
|
const label = item.querySelector('.el-form-item__label');
|
|
const content = item.querySelector('.el-form-item__content');
|
|
if (
|
|
visible(item) &&
|
|
label &&
|
|
content &&
|
|
overlap(label.getBoundingClientRect(), content.getBoundingClientRect())
|
|
)
|
|
problems.push('Form label overlaps its content');
|
|
}
|
|
for (const footer of scope.querySelectorAll('.footer, .dialog-footer')) {
|
|
const buttons = [...footer.querySelectorAll('button, .btn')].filter(visible);
|
|
for (let i = 0; i < buttons.length; i++) {
|
|
for (let j = i + 1; j < buttons.length; j++) {
|
|
if (overlap(buttons[i].getBoundingClientRect(), buttons[j].getBoundingClientRect()))
|
|
problems.push('Footer commands overlap');
|
|
}
|
|
}
|
|
if (footer.classList.contains('footer')) {
|
|
const counter = footer.firstElementChild;
|
|
const commands = footer.lastElementChild;
|
|
if (counter && commands && overlap(counter.getBoundingClientRect(), commands.getBoundingClientRect()))
|
|
problems.push('Phone counter overlaps upload commands');
|
|
}
|
|
}
|
|
for (const image of scope.querySelectorAll('img')) {
|
|
if (visible(image) && (!image.complete || !image.naturalWidth))
|
|
problems.push('Visible bitmap failed to render');
|
|
if (image.classList.contains('el-upload-list__item-thumbnail')) {
|
|
const r = image.getBoundingClientRect();
|
|
if (r.width < 32 || r.height < 32) problems.push('Selected image thumbnail collapsed or hidden');
|
|
else if (Math.abs(r.width - r.height) > 2)
|
|
problems.push('Selected image thumbnail must preserve its square frame');
|
|
}
|
|
}
|
|
return [...new Set(problems)];
|
|
});
|
|
for (const problem of result) issues.push(`${name}: ${problem}`);
|
|
checks++;
|
|
}
|
|
async function state(page) {
|
|
return page.evaluate(() => window.__storageUploadHandle.state());
|
|
}
|
|
async function ready(page, phone) {
|
|
await page.goto(`${origin}/?page=${phone ? 'phone' : 'picker'}`, { waitUntil: 'networkidle' });
|
|
await page.waitForFunction(() => window.__storageUploadHandle);
|
|
if (!phone) {
|
|
await page.evaluate(() => window.__storageUploadHandle.open());
|
|
try {
|
|
await page.getByRole('dialog').waitFor();
|
|
} catch (error) {
|
|
console.error(
|
|
await page.evaluate(() => ({
|
|
state: window.__storageUploadHandle.state(),
|
|
html: document.body.innerHTML.slice(-6000),
|
|
})),
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
async function releaseCompression(page) {
|
|
await page.evaluate(() => {
|
|
window.__holdRealCompression = false;
|
|
for (const run of window.__pendingRealCompression.splice(0)) run();
|
|
});
|
|
await page.waitForFunction(() => window.__storageUploadHandle.state().preparing === 0);
|
|
}
|
|
async function waitForUpload(start, api) {
|
|
const deadline = Date.now() + 10000;
|
|
while (!uploadRequests(start, api).length && Date.now() < deadline)
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.equal(uploadRequests(start, api).length, 1, 'Fixture must receive and parse the first multipart upload');
|
|
}
|
|
async function settledUi(page) {
|
|
await page.waitForFunction(() => !document.querySelector('.el-loading-mask'));
|
|
}
|
|
async function exerciseCompression(page, name, phone) {
|
|
const api = phone ? 'image/scan_upload' : 'file/upload';
|
|
const start = requests.length;
|
|
await page.evaluate(() => {
|
|
window.__holdRealCompression = true;
|
|
});
|
|
await page.locator('input[type="file"]').setInputFiles(validFiles);
|
|
await page.waitForFunction(() => {
|
|
const snapshot = window.__storageUploadHandle.state();
|
|
return snapshot.preparing === 1 && snapshot.files.length === 2 && window.__pendingRealCompression.length === 1;
|
|
});
|
|
await page.evaluate(() => window.__storageUploadHandle.submit());
|
|
assert.equal(uploadRequests(start, api).length, 0, `${name}: pending compression must block submission`);
|
|
if (!phone) {
|
|
assert.equal(await page.locator('.el-dialog__headerbtn').count(), 0, `${name}: pending compression hides close`);
|
|
assert(await page.getByRole('button', { name: labels.cancel, exact: true }).isDisabled());
|
|
await page.keyboard.press('Escape');
|
|
assert.equal((await state(page)).open, true);
|
|
}
|
|
await screenshot(page, name + '-preparing');
|
|
checks++;
|
|
await releaseCompression(page);
|
|
const snapshot = await state(page);
|
|
assert.equal(snapshot.files.length, 2);
|
|
assert.equal(snapshot.files[0].name, 'large-fixture.jpg');
|
|
assert.equal(snapshot.files[0].type, 'image/jpeg');
|
|
if (phone)
|
|
assert.equal(
|
|
snapshot.total,
|
|
snapshot.files.reduce((sum, file) => sum + file.size, 0),
|
|
);
|
|
await settledUi(page);
|
|
await page.waitForFunction(() => [...document.querySelectorAll('img')].every((img) => img.complete));
|
|
await screenshot(page, name + '-prepared');
|
|
await checkLayout(page, name + '-prepared', phone ? '.storage-upload-workspace' : '.el-dialog');
|
|
holdUploads = true;
|
|
if (phone) await page.getByText(labels.upload, { exact: true }).click();
|
|
else await page.getByRole('button', { name: labels.confirm, exact: true }).click();
|
|
await page.waitForFunction(() => window.__storageUploadHandle.state().loading);
|
|
await waitForUpload(start, api);
|
|
assert.equal(uploadRequests(start, api).length, 1);
|
|
await page.evaluate(() => window.__storageUploadHandle.submit());
|
|
assert.equal(uploadRequests(start, api).length, 1, `${name}: duplicate submit must not send another file`);
|
|
if (!phone) {
|
|
assert.equal(await page.locator('.el-dialog__headerbtn').count(), 0, `${name}: upload hides close`);
|
|
assert(await page.getByRole('button', { name: labels.cancel, exact: true }).isDisabled());
|
|
await page.keyboard.press('Escape');
|
|
assert.equal((await state(page)).open, true);
|
|
}
|
|
await screenshot(page, name + '-submitting');
|
|
checks++;
|
|
releaseUploads();
|
|
await page.waitForFunction(() => {
|
|
const snapshot = window.__storageUploadHandle.state();
|
|
return !snapshot.loading && (snapshot.uploading === false || snapshot.open === false);
|
|
});
|
|
verifyUploadedFiles(start, api, name);
|
|
if (phone) {
|
|
await page.getByText(labels.success, { exact: true }).waitFor();
|
|
await settledUi(page);
|
|
await screenshot(page, name + '-success');
|
|
await checkLayout(page, name + '-success', '.storage-upload-workspace');
|
|
} else {
|
|
assert.equal(await page.evaluate(() => window.__uploadSuccesses), 1);
|
|
}
|
|
}
|
|
async function exerciseInvalidImage(page, name, phone) {
|
|
if (phone) await page.getByText(labels.again, { exact: true }).click();
|
|
else {
|
|
await page.evaluate(() => window.__storageUploadHandle.open());
|
|
await page.getByRole('dialog').waitFor();
|
|
}
|
|
const start = requests.length;
|
|
await page.locator('input[type="file"]').setInputFiles(invalidFile);
|
|
await page
|
|
.getByText('\u56fe\u7247\u5185\u5bb9\u65e0\u6cd5\u89e3\u6790\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9', {
|
|
exact: true,
|
|
})
|
|
.waitFor();
|
|
await page.waitForFunction(() => {
|
|
const snapshot = window.__storageUploadHandle.state();
|
|
return snapshot.preparing === 0 && snapshot.files.length === 0;
|
|
});
|
|
assert.equal((await state(page)).loading, false, `${name}: invalid image must release loading`);
|
|
assert.equal(uploadRequests(start, phone ? 'image/scan_upload' : 'file/upload').length, 0);
|
|
await settledUi(page);
|
|
await page.locator('input[type="file"]').setInputFiles(validFiles[1]);
|
|
await page.waitForFunction(() => {
|
|
const files = window.__storageUploadHandle.state().files;
|
|
return files.length === 1 && files[0].name === 'small-fixture.png';
|
|
});
|
|
await page.waitForFunction(() =>
|
|
[...document.querySelectorAll('img')].every((img) => img.complete && img.naturalWidth),
|
|
);
|
|
await screenshot(page, name + '-failure-recovery');
|
|
await checkLayout(page, name + '-failure-recovery', phone ? '.storage-upload-workspace' : '.el-dialog');
|
|
checks++;
|
|
}
|
|
async function exerciseScan(page, name) {
|
|
await page.getByText(labels.scan, { exact: true }).click();
|
|
await page.waitForFunction(
|
|
() => window.__storageUploadHandle.state().type === 2 && document.querySelector('.code img'),
|
|
);
|
|
await page.evaluate(() => window.__storageUploadHandle.poll());
|
|
await page.waitForFunction(() => window.__storageUploadHandle.state().files.length === 3);
|
|
assert.deepEqual(
|
|
(await state(page)).files.map((file) => file.id),
|
|
[1, 2, 3],
|
|
);
|
|
await page.locator('.code-image .right .pictrue .btndel').nth(1).click();
|
|
assert.deepEqual(
|
|
(await state(page)).files.map((file) => file.id),
|
|
[1, 3],
|
|
);
|
|
const refreshed = page.waitForResponse(
|
|
(response) => new URL(response.url()).pathname === `/__storage_upload_api/file/scan_upload/image/${scanToken}`,
|
|
);
|
|
await page.getByRole('button', { name: labels.refresh, exact: true }).click();
|
|
await refreshed;
|
|
await page.evaluate(() => window.__storageUploadHandle.poll());
|
|
assert.deepEqual(
|
|
(await state(page)).files.map((file) => file.id),
|
|
[1, 3],
|
|
`${name}: refresh must keep removed second image excluded`,
|
|
);
|
|
await page.waitForFunction(() =>
|
|
[...document.querySelectorAll('.code-image img')].every((img) => img.complete && img.naturalWidth),
|
|
);
|
|
await screenshot(page, name + '-scan-removed-refreshed');
|
|
await checkLayout(page, name + '-scan-removed-refreshed', '.el-dialog');
|
|
const canvasInk = await page.locator('.code canvas').evaluate((canvas) => {
|
|
const pixels = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
|
|
let dark = 0;
|
|
for (let i = 0; i < pixels.length; i += 4) if (pixels[i] < 100) dark++;
|
|
return dark;
|
|
});
|
|
assert(canvasInk > 1000, `${name}: actual QR canvas must encode nonblank pixels`);
|
|
checks++;
|
|
await page.getByRole('button', { name: labels.cancel, exact: true }).click();
|
|
await page.getByRole('dialog').waitFor({ state: 'hidden' });
|
|
}
|
|
try {
|
|
const port = await freeLoopbackPort();
|
|
server = await createServer({
|
|
configFile: false,
|
|
root: adminRoot,
|
|
cacheDir: path.join(artifacts, 'vite-cache'),
|
|
plugins: [fixturePlugin(), vue()],
|
|
optimizeDeps: {
|
|
noDiscovery: true,
|
|
entries: [],
|
|
include: ['vue', 'vue-router', 'element-plus', '@element-plus/icons-vue'],
|
|
},
|
|
resolve: {
|
|
alias: [
|
|
{ find: '@/libs/request', replacement: 'virtual:storage-upload-request' },
|
|
{ find: '@/libs/util', replacement: 'virtual:storage-upload-util' },
|
|
{ find: '@/setting', replacement: 'virtual:storage-upload-setting' },
|
|
{ find: /^vue$/, replacement: path.join(adminRoot, 'node_modules/vue/dist/vue.esm-bundler.js') },
|
|
{ find: '@', replacement: path.join(adminRoot, 'src') },
|
|
],
|
|
},
|
|
css: {
|
|
preprocessorOptions: {
|
|
scss: {
|
|
api: 'modern-compiler',
|
|
silenceDeprecations: ['legacy-js-api', 'import', 'global-builtin', 'color-functions', 'slash-div'],
|
|
},
|
|
},
|
|
},
|
|
server: { host: '127.0.0.1', port, hmr: false, strictPort: true, fs: { allow: [adminRoot] } },
|
|
logLevel: 'warn',
|
|
});
|
|
await server.listen();
|
|
origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
|
const channel =
|
|
process.env.CRMEB_BROWSER_TEST_CHANNEL || (existsSync(chromium.executablePath()) ? undefined : 'chrome');
|
|
browser = await chromium.launch({ headless: true, channel });
|
|
for (const [name, viewport] of Object.entries({
|
|
desktop: { width: 1440, height: 900 },
|
|
mobile: { width: 390, height: 844 },
|
|
narrow: { width: 320, height: 844 },
|
|
})) {
|
|
const context = await browser.newContext({ viewport });
|
|
try {
|
|
const page = (activePage = await context.newPage());
|
|
page.setDefaultTimeout(10000);
|
|
const errors = [];
|
|
page.on('pageerror', (error) => {
|
|
errors.push(error.message);
|
|
console.error(error.message);
|
|
});
|
|
await context.route('**/*', (route) => {
|
|
if (new URL(route.request().url()).origin === origin) return route.continue();
|
|
errors.push('Unexpected non-loopback network request');
|
|
return route.abort();
|
|
});
|
|
await context.addInitScript(() => {
|
|
for (const method of ['setItem', 'removeItem', 'clear']) {
|
|
Storage.prototype[method] = () => {
|
|
throw new Error('Isolated test must not mutate business browser storage');
|
|
};
|
|
}
|
|
window.__pendingRealCompression = [];
|
|
window.__holdRealCompression = false;
|
|
const original = HTMLCanvasElement.prototype.toBlob;
|
|
HTMLCanvasElement.prototype.toBlob = function (...args) {
|
|
const run = () => original.apply(this, args);
|
|
if (window.__holdRealCompression) window.__pendingRealCompression.push(run);
|
|
else run();
|
|
};
|
|
});
|
|
if (process.env.CRMEB_UPLOAD_BROWSER_ONLY_PHONE !== '1') {
|
|
await ready(page, false);
|
|
await exerciseCompression(page, name + '-picker', false);
|
|
await exerciseInvalidImage(page, name + '-picker', false);
|
|
await exerciseScan(page, name + '-picker');
|
|
}
|
|
await ready(page, true);
|
|
await exerciseCompression(page, name + '-phone', true);
|
|
await exerciseInvalidImage(page, name + '-phone', true);
|
|
assert.deepEqual(errors, [], `${name}: real SFCs must run without uncaught browser errors`);
|
|
console.log(
|
|
process.env.CRMEB_UPLOAD_BROWSER_ONLY_PHONE === '1'
|
|
? `PASS: ${name} real phone compression, MIME/content, guarded submit and bad-image recovery`
|
|
: `PASS: ${name} real picker/phone compression, MIME/content, guarded close/submit, bad-image recovery and scanner exclusion`,
|
|
);
|
|
} catch (error) {
|
|
if (activePage && !activePage.isClosed()) await screenshot(activePage, name + '-failure').catch(() => {});
|
|
throw error;
|
|
} finally {
|
|
releaseUploads();
|
|
activePage = undefined;
|
|
await context.close();
|
|
}
|
|
}
|
|
assert.deepEqual(issues, [], 'Visible upload controls and assets must fit without overlap or fixture errors');
|
|
console.log(
|
|
`PASS: ${checks} upload/render scenario checks across 3 viewports; ${screenshots.length} nonblank screenshots`,
|
|
);
|
|
} catch (error) {
|
|
console.error(error.stack);
|
|
for (const issue of issues) console.error(issue);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
releaseUploads();
|
|
try {
|
|
await browser?.close();
|
|
} finally {
|
|
await server?.close();
|
|
console.log(`Retained isolated upload screenshots: ${artifacts}`);
|
|
for (const filename of screenshots) console.log(filename);
|
|
}
|
|
}
|
|
}
|
|
run().catch((error) => {
|
|
console.error(error.stack);
|
|
process.exitCode = 1;
|
|
});
|