|
- import assert from 'node:assert/strict';
- import fs from 'node:fs';
- import path from 'node:path';
- import { fileURLToPath } from 'node:url';
- import { chromium, expect } from '@playwright/test';
- import { localCredentials } from './review-api.mjs';
-
- // Run against the local Vite application after saving the intended scene camera:
- // node tools/scene-model-visibility.e2e.mjs
- // Every scene uses a fresh browser context. Content and asset writes are blocked;
- // the only server mutations permitted are login and active-role session changes.
- const baseURL = process.env.SCENE_VISIBILITY_BASE_URL || 'http://127.0.0.1:6180';
- const reportDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../reports/scene-live-20260905');
- const cases = [
- { sceneId: '160', modelProjectId: '157', modelVersionId: '201', label: '狐狸野外训练区', screenshot: '26-fox-visual-regression.png' },
- { sceneId: '159', modelProjectId: '156', modelVersionId: '204', label: '牛奶卡车检验区', screenshot: '27-truck-visual-regression.png' },
- ];
- const credentials = localCredentials();
- const evidence = {
- generatedAt: new Date().toISOString(),
- baseURL,
- browser: 'Microsoft Edge / Playwright',
- method: 'Fresh context; saved camera; compare rendered pixels with model roots visible versus hidden; model shadow casting disabled in both samples.',
- threshold: { sampleWidth: 320, sampleHeight: 180, minimumChangedPixelsExclusive: 500, minimumRgbDelta: 12 },
- status: 'RUNNING',
- results: [],
- };
- fs.mkdirSync(path.join(reportDir, 'screenshots'), { recursive: true });
-
- function persist() {
- fs.writeFileSync(path.join(reportDir, 'visibility-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`);
- }
-
- function safeError(error) {
- let message = error instanceof Error ? error.message : String(error);
- for (const value of [credentials.username, credentials.password]) {
- if (value) message = message.split(value).join('[redacted]');
- }
- return message.replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
- }
-
- async function signIn(page) {
- await page.goto('/login', { waitUntil: 'domcontentloaded' });
- await expect(page.locator('[data-role-code="admin"]')).toBeVisible({ timeout: 45_000 });
- await page.locator('[data-role-code="admin"]').click();
- await page.locator('input[name="username"]').fill(credentials.username);
- await page.locator('input[name="password"]').fill(credentials.password);
- await page.getByRole('button', { name: '登录系统', exact: true }).click();
- await expect(page.locator('.platform-shell')).toBeVisible({ timeout: 60_000 });
- await page.goto('/profile', { waitUntil: 'domcontentloaded' });
- const teacher = page.locator('.profile-role-switcher button').filter({ hasText: '教员' });
- await expect(teacher).toBeVisible();
- let roleSwitchAttempts = 0;
- while (!(await teacher.getAttribute('class'))?.includes('active') && roleSwitchAttempts < 3) {
- roleSwitchAttempts += 1;
- await teacher.click();
- try {
- await expect(teacher).toHaveClass(/active/, { timeout: 15_000 });
- } catch {
- // The local auth service occasionally rejects the initial role switch.
- // Retry only while the requested role is still inactive; never retry or
- // weaken any scene, version, visibility or pixel-contribution assertion.
- if (roleSwitchAttempts === 3) throw new Error('Teacher role remained inactive after three session-switch attempts');
- }
- }
- await expect(teacher).toHaveClass(/active/);
- return { role: 'teacher', roleSwitchAttempts };
- }
-
- async function readPixelContribution(page, testCase) {
- return page.evaluate(async ({ modelProjectId, modelVersionId }) => {
- const editor = globalThis.__visibilityScene;
- const THREE = globalThis.__visibilityThree;
- if (!editor?.readySettled || editor.destroyed) throw new Error('Scene editor is not ready');
- if (!THREE) throw new Error('The editor Three runtime was not exposed');
- editor.selectObject(null);
- const roots = editor.objects.filter(object => String(object.userData?.targetProjectId) === modelProjectId);
- if (!roots.length) throw new Error(`Scene contains no model reference for project ${modelProjectId}`);
- if (roots.some(object => String(object.userData?.targetVersionId) !== modelVersionId)) {
- throw new Error(`Scene references an unexpected published model version for project ${modelProjectId}`);
- }
-
- const inheritedVisible = object => {
- for (let node = object; node; node = node.parent) if (!node.visible) return false;
- return true;
- };
- const measurements = roots.map(root => {
- root.updateMatrixWorld(true);
- const bounds = new THREE.Box3().setFromObject(root);
- const size = bounds.getSize(new THREE.Vector3());
- let meshes = 0;
- let visibleMeshes = 0;
- let skinnedMeshes = 0;
- root.traverse(node => {
- if (!node.isMesh) return;
- meshes += 1;
- if (inheritedVisible(node)) visibleMeshes += 1;
- if (node.isSkinnedMesh) skinnedMeshes += 1;
- });
- return {
- name: root.userData.displayName || root.name,
- sceneObjectId: root.userData.sceneId,
- targetProjectId: String(root.userData.targetProjectId),
- targetVersionId: String(root.userData.targetVersionId),
- visible: inheritedVisible(root), meshes, visibleMeshes, skinnedMeshes,
- position: root.position.toArray(), scale: root.scale.toArray(),
- bounds: { min: bounds.min.toArray(), max: bounds.max.toArray(), size: size.toArray() },
- finiteBounds: !bounds.isEmpty() && [...bounds.min.toArray(), ...bounds.max.toArray()].every(Number.isFinite),
- };
- });
-
- const sample = document.createElement('canvas');
- sample.width = 320;
- sample.height = 180;
- const sampleContext = sample.getContext('2d', { willReadFrequently: true });
- if (!sampleContext) throw new Error('2D pixel sampling is unavailable');
- const readPixels = () => {
- editor.renderer.render(editor.scene, editor.camera);
- sampleContext.clearRect(0, 0, sample.width, sample.height);
- sampleContext.drawImage(editor.renderer.domElement, 0, 0, sample.width, sample.height);
- return sampleContext.getImageData(0, 0, sample.width, sample.height).data;
- };
- const visibility = roots.map(root => root.visible);
- const shadowCasters = [];
- let before;
- let withoutModels;
- try {
- // A shadow alone must not satisfy the visibility check.
- for (const root of roots) root.traverse(node => {
- if (node.isMesh) { shadowCasters.push([node, node.castShadow]); node.castShadow = false; }
- });
- editor.renderer.shadowMap.needsUpdate = true;
- before = readPixels();
- for (const root of roots) root.visible = false;
- editor.renderer.shadowMap.needsUpdate = true;
- withoutModels = readPixels();
- } finally {
- roots.forEach((root, index) => { root.visible = visibility[index]; });
- shadowCasters.forEach(([mesh, castShadow]) => { mesh.castShadow = castShadow; });
- editor.renderer.shadowMap.needsUpdate = true;
- editor.renderer.render(editor.scene, editor.camera);
- }
- let changedPixels = 0;
- let rgbDeltaSum = 0;
- for (let offset = 0; offset < before.length; offset += 4) {
- const delta = Math.abs(before[offset] - withoutModels[offset])
- + Math.abs(before[offset + 1] - withoutModels[offset + 1])
- + Math.abs(before[offset + 2] - withoutModels[offset + 2]);
- rgbDeltaSum += delta;
- if (delta >= 12) changedPixels += 1;
- }
- return {
- camera: { position: editor.camera.position.toArray(), target: editor.controls.target.toArray(), fov: editor.camera.fov },
- ready: editor.readySettled, pendingLoads: editor.pendingLoads, modelRoots: measurements,
- sample: { width: 320, height: 180, changedPixels, changedPercentage: Number((changedPixels / 576).toFixed(3)), rgbDeltaSum },
- };
- }, testCase);
- }
-
- const browser = await chromium.launch({ channel: 'msedge', headless: true });
- try {
- for (const testCase of cases) {
- const blockedWrites = [];
- const context = await browser.newContext({ baseURL, viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' });
- const result = { ...testCase, status: 'RUNNING', blockedWrites };
- evidence.results.push(result);
- try {
- await context.route('**/*', async route => {
- const request = route.request();
- const pathname = new URL(request.url()).pathname;
- if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method()) && (pathname.startsWith('/api/tran/') || pathname.startsWith('/fileapi/'))) {
- blockedWrites.push({ method: request.method(), path: pathname });
- await route.abort('blockedbyclient');
- return;
- }
- await route.continue();
- });
- const page = await context.newPage();
- page.setDefaultTimeout(45_000);
- // Read handles are injected into the module already loaded by the page.
- // Re-importing an unversioned URL after HMR would create a second editor
- // module and observe the wrong instance. No business logic is replaced.
- await page.route('**/src/features/guide-legacy/demo-src/scene-editor.js*', async route => {
- const response = await route.fetch();
- const body = await response.text();
- const marker = 'activeSceneEditor = new WorkshopSceneEditor(container, context);';
- assert.ok(body.includes(marker), 'Scene editor instrumentation anchor must exist');
- await route.fulfill({ response, body: body.replace(marker,
- `${marker} globalThis.__visibilityScene = activeSceneEditor; globalThis.__visibilityThree = THREE;`) });
- });
- result.session = await signIn(page);
- const sceneRead = page.waitForResponse(response => response.request().method() === 'GET'
- && new URL(response.url()).pathname === `/api/tran/v1/content/projects/${testCase.sceneId}`);
- await page.goto(`/content/scenes/${testCase.sceneId}/edit`, { waitUntil: 'domcontentloaded' });
- const response = await sceneRead;
- assert.equal(response.status(), 200, 'Scene project API must return 200');
- const { data } = await response.json();
- result.project = { id: String(data.project.id), name: data.project.name, status: data.project.status, currentVersionId: String(data.currentVersion.id), publishedVersionId: String(data.project.publishedVersionId) };
- const shell = page.locator('.se-shell');
- await expect(shell).toHaveAttribute('data-ready', 'true', { timeout: 180_000 });
- await expect(shell).not.toHaveClass(/model-loading/, { timeout: 180_000 });
- result.visibility = await readPixelContribution(page, testCase);
- result.screenshot = `screenshots/${testCase.screenshot}`;
- await page.screenshot({ path: path.join(reportDir, result.screenshot), fullPage: true, timeout: 60_000 });
- assert.ok(result.visibility.modelRoots.every(root => root.visible && root.visibleMeshes > 0 && root.finiteBounds), 'Model meshes and finite bounds must be visible');
- assert.ok(result.visibility.sample.changedPixels > 500, `Expected more than 500 model-contributed pixels; received ${result.visibility.sample.changedPixels}`);
- assert.equal(blockedWrites.length, 0, 'Read-only regression must not attempt content or file writes');
- result.status = 'PASS';
- console.log(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, changedPixels: result.visibility.sample.changedPixels, screenshot: result.screenshot }));
- } catch (error) {
- result.status = 'FAIL';
- result.error = safeError(error);
- console.error(JSON.stringify({ sceneId: testCase.sceneId, status: result.status, error: result.error }));
- } finally {
- await context.close();
- persist();
- }
- }
- evidence.status = evidence.results.every(result => result.status === 'PASS') ? 'PASS' : 'FAIL';
- persist();
- if (evidence.status !== 'PASS') process.exitCode = 1;
- } finally {
- await browser.close();
- }
|