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(); }