diff --git a/docs/PERFORMANCE-HANDOFF.md b/docs/PERFORMANCE-HANDOFF.md index d60545b..8304ee5 100644 --- a/docs/PERFORMANCE-HANDOFF.md +++ b/docs/PERFORMANCE-HANDOFF.md @@ -1,6 +1,6 @@ # Performance investigation — OPEN -Updated 2026-09-10. **Do not treat the Godot performance problem as resolved.** +Updated 2026-09-11. **Do not treat the Godot performance problem as resolved.** The development laptop experienced severe system-wide slowdowns, initially near the first fab and later near the second. The user still reported excessive @@ -68,3 +68,23 @@ workflow are tracked; generated engine binaries and web exports are not. The games have separate browser-local saves. Changing browser, machine, or origin does not transfer progress automatically. Git transfers the project, not a running game's browser storage. + + +## Cross-device report and graphics controls — 2026-09-11 + +The user confirms remaining slowdown as the factory scales on both the development +laptop and a phone, without a consistent two-fab threshold. The earlier smoother +hosted run did not establish a fix. Browser versions and phone model are unknown. + +The lost edge smoothing corresponds to disabling MSAA during the earlier +performance investigation. New graphics controls expose off/2×/4× MSAA, +1280×800 / 1440×900 / 1920×1200 resolution caps, and shadows. Defaults retain the +previous rendering budget. Preferences are independent of simulation saves. + +An opt-in, bounded 60-sample local JSON recording reports actual settings, fab +count, frame intervals, rendering counters and node count. Optional JS heap is +approximate and excludes total browser/GPU/system memory. Use identical camera, +resolution and sound when comparing fab counts; then vary AA and shadows +separately. CI software rendering can validate controls and bounded allocations, +but cannot establish performance on the affected laptop or phone. No local game, +browser or server should be launched to test these changes. diff --git a/godot/README.md b/godot/README.md index 9ff7957..940efe8 100644 --- a/godot/README.md +++ b/godot/README.md @@ -52,7 +52,7 @@ are retained. On other platforms, install the matching editor and set | New Run | Confirm and reset this prototype's save | Touch users can use the action dock and drag the scene. Each etch consumes one -wafer and pays $100 when finished. Twelve chips finance the first fab. Reserve +wafer and pays $100 when finished. New runs start with $500; seven chips finance the first fab. Existing saves retain their balance. Reserve $600 for a shipment of 30 wafers. If you run out of both silicon and money, procurement offers three reclaimed wafers so the run cannot become stranded. @@ -73,7 +73,7 @@ purchasing and production reuse those objects. Machine monitors show cycle state instead of rebuilding percentage text throughout every cycle. Rendering is capped at 30 fps and at a 1440 × 900 internal viewport (preserving -aspect ratio on smaller or portrait screens). MSAA and the full-screen mipmap +aspect ratio on smaller or portrait screens). By default MSAA and the full-screen mipmap bloom pass are disabled to reduce GPU load. This trades some edge sharpness and glow for a lower rendering budget on laptops. The simulation still advances by elapsed time; the cap does not halve production speed. @@ -167,3 +167,23 @@ The performance investigation remains open. Version 2 saves keep the original `the-seed-v1.json` location and accept version 1 factory saves. The browser regression fixture is enabled only by `?test=1&scenario=chorus`; normal play exposes no state setter. + + +## Graphics and performance recording + +Open **Graphics / controls** below the game to select off, 2× or 4× MSAA, +change the internal resolution cap, or toggle shadows. These preferences persist +separately from your run. Defaults retain the previous budget: AA off, 1440 × 900 +maximum, shadows on, 30 fps. Try 2× MSAA for smoother geometry; higher resolution +and 4× MSAA cost more GPU work. Bloom remains disabled. + +The same panel can record up to 60 one-second samples while you play, then +download a local JSON report. It includes fab count, settings, frame intervals, +draw calls, rendered primitives, node count, and approximate JavaScript heap +where the browser exposes it. Frame intervals include the frame limiter; +JavaScript heap is **not** total browser, GPU, or system memory. Recording stops +when the tab is hidden. Nothing is uploaded. The scaling problem remains open. + +Remote browser checks use `?test=1&scenario=scaling` (one funded, muted fab), +then real build controls to compare two and six fabs and record AA/shadow settings. +This fixture is available only in test mode; it does not replace normal saves. diff --git a/godot/scripts/factory.gd b/godot/scripts/factory.gd index e4a14d2..1727d9f 100644 --- a/godot/scripts/factory.gd +++ b/godot/scripts/factory.gd @@ -41,6 +41,11 @@ var selected_ring: MeshInstance3D var debug_timer: float = 0.0 var fps_samples: int = 0 var reset_pending: bool = false +var render_cap: int = 1440 +var graphics_timer: float = 0.0 +var sample_time: float = 0.0 +var sample_intervals: Array[float] = [] +var was_recording: bool = false func _ready() -> void: Engine.max_fps=30 @@ -60,6 +65,8 @@ func _ready() -> void: sim.restore({"version":1, "capital":60000, "wafers":300, "chips":1200, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false}) if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=firstlight"): sim.restore({"version":2, "capital":30000, "wafers":300, "chips":2000, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false, "places":[7,1,0], "signals":1000, "resonance":1500.0, "charter":0}) + if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=scaling"): + sim.restore({"version":1, "capital":60000, "wafers":300, "chips":0, "fabs":1, "sound_enabled":false}) _create_environment() room=Chamber.new() add_child(room) @@ -80,6 +87,7 @@ func _ready() -> void: camera_focus=sim.fabs==0 _create_camera() _create_audio() + _update_graphics(1.0) ui=Interface.new() add_child(ui) ui.action_requested.connect(_action) @@ -141,11 +149,53 @@ func _limit_render_size() -> void: if window.size==last_window_size:return last_window_size=window.size var physical:=Vector2(window.size) - var factor:=minf(1.0,minf(1440.0/maxf(1,physical.x),900.0/maxf(1,physical.y))) + var factor:=minf(1.0,minf(float(render_cap)/maxf(1,physical.x),float(render_cap)*0.625/maxf(1,physical.y))) var target:=Vector2i((physical*factor).round()) window.content_scale_mode=Window.CONTENT_SCALE_MODE_VIEWPORT if window.content_scale_size!=target:window.content_scale_size=target +func _update_graphics(delta: float) -> void: + if not OS.has_feature("web"):return + var browser_window = JavaScriptBridge.get_interface("window") + var settings = browser_window.seedGraphics + if settings == null:return + graphics_timer += delta + if graphics_timer >= 0.5: + graphics_timer = 0.0 + var aa: int = clampi(int(settings.aa), 0, 2) + if int(get_viewport().msaa_3d) != aa:get_viewport().msaa_3d = aa as Viewport.MSAA + var cap: int = int(settings.cap) + if cap in [1280, 1440, 1920] and render_cap != cap: + render_cap = cap + last_window_size = Vector2i.ZERO + if key_light.shadow_enabled != bool(settings.shadows):key_light.shadow_enabled = bool(settings.shadows) + var recording: bool = bool(settings.recording) + if recording and not was_recording: + sample_time = 0.0 + sample_intervals.clear() + was_recording = recording + if not recording:return + sample_time += delta + # Bound recording storage even if the frame cap is changed later. + if sample_intervals.size() < 240:sample_intervals.append(delta * 1000.0) + if sample_time < 1.0:return + sample_intervals.sort() + var sample: Dictionary = { + "fps": Engine.get_frames_per_second(), "window_seconds": sample_time, + "frame_interval_p95_ms": sample_intervals[mini(sample_intervals.size()-1, floori(sample_intervals.size()*0.95))], + "frame_interval_max_ms": sample_intervals.back(), + "render_width": get_viewport().get_visible_rect().size.x, + "render_height": get_viewport().get_visible_rect().size.y, + "msaa": int(get_viewport().msaa_3d), "shadows": key_light.shadow_enabled, + "fabs": sim.fabs, "chips": sim.chips, "sound_enabled": sim.sound_enabled, + "atlas": atlas, "nodes": get_tree().get_node_count(), + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), + "rendered_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME), + } + browser_window.seedReceivePerformance(JavaScriptBridge.get_interface("JSON").parse(JSON.stringify(sample))) + sample_time = 0.0 + sample_intervals.clear() + func _create_audio() -> void: for id in ["etch","chip","build","supply","uplink"]: sounds[id]=load("res://assets/audio/"+id+".wav") @@ -170,6 +220,7 @@ func _sound(id: String, volume: float=-15.0) -> void: voice.play() func _process(delta: float) -> void: + _update_graphics(delta) _limit_render_size() # A short fixed step keeps production deterministic across render frame rates. # Browser tab suspension does not mint unobserved chips in this prototype. @@ -210,6 +261,8 @@ func _process(delta: float) -> void: snapshot["nodes"]=get_tree().get_node_count() snapshot["render_width"]=get_viewport().get_visible_rect().size.x snapshot["render_height"]=get_viewport().get_visible_rect().size.y + snapshot["msaa"]=get_viewport().msaa_3d + snapshot["shadows"]=key_light.shadow_enabled snapshot["atlas"]=atlas snapshot["cinema"]=ui.focus_mode snapshot["camera_focus"]=camera_focus @@ -292,7 +345,7 @@ func _handle_events() -> void: _sound("chip",-23 if index>=0 else -16) production_chime=0.18 if sim.chips==1:ui.notify("First chip shipped. $100. A very small beginning.",true) - elif sim.chips==12 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true) + elif sim.chips==7 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true) "build": _add_machine(int(event.machine)) camera_focus=false diff --git a/godot/scripts/interface.gd b/godot/scripts/interface.gd index 3830e53..20f3ebe 100644 --- a/godot/scripts/interface.gd +++ b/godot/scripts/interface.gd @@ -101,7 +101,7 @@ func _ready() -> void: chapter_box.add_child(_label("O B J E C T I V E / 0 1",10,GOLD)) chapter_title=_label("Build the machine\nthat builds the machine.",22,WHITE) chapter_box.add_child(chapter_title) - chapter_note=_label("Etch 12 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED) + chapter_note=_label("Etch 7 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED) chapter_note.autowrap_mode=TextServer.AUTOWRAP_WORD_SMART chapter_box.add_child(chapter_note) var sep:=HSeparator.new() @@ -121,7 +121,7 @@ func _ready() -> void: fill.content_margin_bottom=0 objective_progress.add_theme_stylebox_override("fill",fill) chapter_box.add_child(objective_progress) - objective_numbers=_label("$0 / $1,200",11,MUTED) + objective_numbers=_label("$500 / $1,200",11,MUTED) chapter_box.add_child(objective_numbers) chapter_box.add_child(_label("S Y S T E M T R A N S M I S S I O N",9,MUTED)) journal=_label("The room is quiet.\nThat part is temporary.",13,WHITE) diff --git a/godot/scripts/simulation.gd b/godot/scripts/simulation.gd index 02cceff..d2f6ecb 100644 --- a/godot/scripts/simulation.gd +++ b/godot/scripts/simulation.gd @@ -12,7 +12,7 @@ const OVERCLOCK_COST: int = 2400 const UPLINK_COST: int = 6000 const SAVE_VERSION: int = 2 -var capital: int = 0 +var capital: int = 500 var wafers: int = 60 var chips: int = 0 var fabs: int = 0 diff --git a/godot/tests/test_simulation.gd b/godot/tests/test_simulation.gd index 47df7f0..08fac49 100644 --- a/godot/tests/test_simulation.gd +++ b/godot/tests/test_simulation.gd @@ -19,11 +19,11 @@ func _initialize() -> void: check(sim.wafers==59 and sim.chips==0,"Starting a cycle consumes one wafer but grants no output early") check(not sim.etch(),"Manual cycle cannot be double queued") advance(sim,1.0) - check(sim.chips==1 and sim.capital==100,"Completed fabrication sells one chip for $100") - for i in 11: + check(sim.chips==1 and sim.capital==600,"New runs start with $500 and a completed chip adds $100") + for i in 6: sim.etch() advance(sim,1) - check(sim.capital==1200,"Twelve manual chips finance the first machine") + check(sim.capital==1200,"Seven manual chips plus starting capital finance the first machine") check(sim.build_fab() and sim.fabs==1 and sim.capital==0,"The first fab debits the displayed price") var before: int=sim.chips advance(sim,3.5) diff --git a/godot/web/boot.js b/godot/web/boot.js index eaa718e..c1fe10a 100644 --- a/godot/web/boot.js +++ b/godot/web/boot.js @@ -5,6 +5,70 @@ const loading = document.getElementById('loading'); const status = document.getElementById('status'); const retry = document.getElementById('retry'); const controls = document.getElementById('controls'); +// Preferences do not touch the simulation save or silently raise the GPU budget. +const graphicsKey = 'the-seed-graphics-v1'; +let preferences = {}; +try { preferences = JSON.parse(localStorage.getItem(graphicsKey) || '{}') || {}; } catch {} +window.seedGraphics = { + aa: [0, 1, 2].includes(preferences.aa) ? preferences.aa : 0, + cap: [1280, 1440, 1920].includes(preferences.cap) ? preferences.cap : 1440, + shadows: typeof preferences.shadows === 'boolean' ? preferences.shadows : true, + recording: false, +}; +const aaControl = document.getElementById('graphics-aa'); +const capControl = document.getElementById('graphics-cap'); +const shadowControl = document.getElementById('graphics-shadows'); +aaControl.value = String(window.seedGraphics.aa); +capControl.value = String(window.seedGraphics.cap); +shadowControl.checked = window.seedGraphics.shadows; +for (const control of [aaControl, capControl, shadowControl]) control.addEventListener('change', () => { + Object.assign(window.seedGraphics, { aa: Number(aaControl.value), cap: Number(capControl.value), shadows: shadowControl.checked }); + try { + localStorage.setItem(graphicsKey, JSON.stringify({ aa: window.seedGraphics.aa, cap: window.seedGraphics.cap, shadows: window.seedGraphics.shadows })); + document.getElementById('graphics-storage').textContent = 'Settings saved.'; + } catch { document.getElementById('graphics-storage').textContent = 'Storage unavailable: settings apply to this session only.'; } +}); +const recordButton = document.getElementById('record-performance'); +const downloadButton = document.getElementById('download-performance'); +const recordingStatus = document.getElementById('performance-status'); +let samples = []; +let recordingStarted = null; +function stopRecording() { + window.seedGraphics.recording = false; + recordButton.textContent = 'Record 60 seconds'; + downloadButton.disabled = samples.length === 0; + recordingStatus.textContent = `${samples.length} samples recorded. Report stays on this machine.`; +} +recordButton.addEventListener('click', () => { + if (window.seedGraphics.recording) { stopRecording(); return; } + samples = []; + recordingStarted = new Date().toISOString(); + window.seedGraphics.recording = true; + recordButton.textContent = 'Stop recording'; + downloadButton.disabled = true; + recordingStatus.textContent = 'Recording while you play. You can close this panel.'; +}); +// Called once per measured second by Godot, only during an explicit recording. +window.seedReceivePerformance = sample => { + if (!window.seedGraphics.recording || samples.length >= 60) return; + const heap = performance.memory; + samples.push({ ...sample, js_heap_bytes_approx: heap ? heap.usedJSHeapSize : null }); + recordingStatus.textContent = `${samples.length}/60 samples · ${sample.fps} fps · ${sample.render_width} × ${sample.render_height} · AA ${['off', '2×', '4×'][sample.msaa]}`; + if (samples.length >= 60) stopRecording(); +}; +document.addEventListener('visibilitychange', () => { + // Do not mix background throttling into a foreground comparison. + if (document.hidden && window.seedGraphics.recording) stopRecording(); +}); +downloadButton.addEventListener('click', () => { + const report = { schema: 1, started_at: recordingStarted, browser: navigator.userAgent, + device_pixel_ratio: devicePixelRatio, screen: { width: screen.width, height: screen.height }, + limitations: 'Frame intervals include the 30 fps limiter. JavaScript heap is approximate and excludes total browser/GPU/system memory. No performance-fix conclusion is implied.', samples }; + const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; link.download = 'the-seed-performance.json'; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +}); if (location.port === '4180' && ['localhost', '127.0.0.1'].includes(location.hostname)) { document.getElementById('versions-link').href = 'http://localhost:3000/'; } @@ -32,6 +96,6 @@ if (typeof Engine === 'undefined') { document.getElementById('bar').style.width = `${current / total * 100}%`; status.textContent = current < total ? `DELIVERING THE MACHINERY · ${Math.round(current / total * 100)}%` : 'WARMING UP THE FABRICATION FLOOR'; } - } }).then(() => { loading.remove(); canvas.focus(); }, failure); + } }).then(() => { loading.remove(); canvas.focus(); recordButton.disabled = false; recordingStatus.textContent = 'Ready to record.'; }, failure); } } diff --git a/godot/web/shell.html b/godot/web/shell.html index 9948412..7d1b1f3 100644 --- a/godot/web/shell.html +++ b/godot/web/shell.html @@ -8,7 +8,7 @@ Universal AI — The Seed $GODOT_HEAD_INCLUDE @@ -18,7 +18,14 @@ Your browser needs WebGL 2 to run this game.
UNIVERSAL AI / CHAPTER ZERO

The Seed.

A quiet room. A silicon wafer.
An objective that doesn't know when to stop.

PREPARING THE FABRICATION FLOOR
BUILT IN GODOT · RUNS ON YOUR MACHINE
- -

The first machine.

← All versions

Etch chips and sell them for $100 each. Install your first fab at $1,200, then grow to six machines and connect the district.

Progress saves in this browser. Production pauses while the tab is suspended. This is a standalone prototype; earlier Universal AI saves are separate.

+ +

Graphics & controls

+
Rendering +


+


+

+

Changes apply immediately and stay in this browser, separately from your game save. Rendering stays capped at 30 fps. Start with 2× AA at the current resolution; increase one setting at a time.

+

+
Performance recording

Record up to 60 seconds while playing, then download a local report. It includes frame intervals, rendering counters, settings, browser details, and approximate JavaScript heap size where available. It cannot measure total browser memory, GPU memory pressure, or other apps. Nothing is uploaded.

Waiting for the game.

← All versions

Start with $500. Etch seven chips and sell them for $100 each to fund the first fab. Install your first fab at $1,200, then grow to six machines and connect the district.

Progress saves in this browser. Production pauses while the tab is suspended. This is a standalone prototype; earlier Universal AI saves are separate.

diff --git a/tests/godot.e2e.ts b/tests/godot.e2e.ts index 546caff..06d4269 100644 --- a/tests/godot.e2e.ts +++ b/tests/godot.e2e.ts @@ -1,7 +1,8 @@ +import { readFile } from 'node:fs/promises'; import { test, expect, type Page } from '@playwright/test'; interface SeedSnapshot { - nodes: number; fps: number; render_width: number; render_height: number; + msaa: number; shadows: boolean; nodes: number; fps: number; render_width: number; render_height: number; chips: number; capital: number; wafers: number; fabs: number; machines_visible: number; cinema: boolean; camera_focus: boolean; sound_enabled: boolean; manual_progress: number; @@ -26,6 +27,7 @@ test('real WebGL fabrication finances and installs an autonomous machine', async page.on('request', request => { if (/^https?:/.test(request.url()) && !request.url().startsWith('http://127.0.0.1:4180/')) external.push(request.url()); }); await ready(page); expect((await state(page)).fabs).toBe(0); + expect((await state(page)).capital).toBe(500); await page.keyboard.down('Space'); await expect.poll(async () => (await state(page)).capital, { timeout: 60_000 }).toBeGreaterThanOrEqual(1200); await page.keyboard.up('Space'); @@ -58,12 +60,18 @@ test('Godot local storage preserves a run through a browser reload', async ({ pa await expect.poll(async () => (await state(page)).chips, { timeout: 15_000 }).toBe(1); await page.keyboard.press('m'); await expect.poll(async () => (await state(page)).sound_enabled).toBe(false); + await page.getByRole('button', { name: 'Open game controls' }).click(); + await page.locator('#graphics-aa').selectOption('1'); + await expect.poll(async () => (await state(page)).msaa).toBe(1); + await page.getByRole('button', { name: 'Back to the factory' }).click(); // The scene saves every two seconds; allow the web filesystem to synchronize. await page.waitForTimeout(3500); await page.reload(); await expect.poll(async () => (await state(page))?.chips, { timeout: 60_000 }).toBe(1); expect((await state(page)).sound_enabled).toBe(false); expect((await state(page)).wafers).toBe(59); + expect((await state(page)).capital).toBe(600); + await expect.poll(async () => (await state(page)).msaa).toBe(1); }); test('the portrait control dock can fabricate with a tap', async ({ page }) => { @@ -191,3 +199,60 @@ test('first light requires a completed district and survives a real browser save expect((await ending()).atlas).toBe(true); expect(errors).toEqual([]); }); + + +test('graphics controls record factory scaling with actual AA and shadow settings', async ({ page }, testInfo) => { + // Six live-rendering comparisons include costly MSAA on CI's software GPU. + test.setTimeout(240_000); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await page.setViewportSize({ width: 1440, height: 900 }); + await ready(page, '?test=1&scenario=scaling'); + const nodes = (await state(page)).nodes; + const reports: unknown[] = []; + for (const [fabs, aa, shadows] of [[1, 0, true], [2, 0, true], [6, 0, true], [6, 1, true], [6, 2, true], [6, 0, false]] as const) { + while ((await state(page)).fabs < fabs) { + const before = (await state(page)).fabs; + await page.keyboard.press('b'); + await expect.poll(async () => (await state(page)).fabs).toBe(before + 1); + } + await page.getByRole('button', { name: 'Open game controls' }).click(); + await page.locator('#graphics-aa').selectOption(String(aa)); + await page.locator('#graphics-cap').selectOption('1280'); + await page.locator('#graphics-shadows').setChecked(shadows); + await expect.poll(async () => (await state(page)).msaa).toBe(aa); + await expect.poll(async () => (await state(page)).shadows).toBe(shadows); + await expect.poll(async () => (await state(page)).render_width).toBe(1280); + await page.locator('#controls details').locator('summary').click(); + await page.locator('#record-performance').click(); + await page.getByRole('button', { name: 'Back to the factory' }).click(); + // Measure the unobscured factory, with actual simulation and rendering active. + await expect(page.locator('#performance-status')).toHaveText(/^4\/60 samples/, { timeout: 15_000 }); + await page.screenshot({ path: testInfo.outputPath(`fabs-${fabs}-aa-${aa}-shadows-${shadows}.png`) }); + await page.getByRole('button', { name: 'Open game controls' }).click(); + await page.locator('#record-performance').click(); + const downloadEvent = page.waitForEvent('download'); + await page.locator('#download-performance').click(); + const download = await downloadEvent; + // The list reporter does not persist in-memory attachments on successful runs. + await download.saveAs(testInfo.outputPath(`fabs-${fabs}-aa-${aa}-shadows-${shadows}.json`)); + const report = JSON.parse(await readFile((await download.path())!, 'utf8')); + expect(report.samples.length).toBeGreaterThanOrEqual(4); + expect(report.samples.length).toBeLessThanOrEqual(60); + for (const sample of report.samples) { + expect(sample.fabs).toBe(fabs); + expect(sample.msaa).toBe(aa); + expect(sample.shadows).toBe(shadows); + expect(sample.nodes).toBe(nodes); + expect(sample.draw_calls).toBeGreaterThan(0); + expect(sample.frame_interval_p95_ms).toBeGreaterThan(0); + } + reports.push(report); + await testInfo.attach(`fabs-${fabs}-aa-${aa}-shadows-${shadows}`, { body: JSON.stringify(report, null, 2), contentType: 'application/json' }); + await page.locator('#controls details').locator('summary').click(); + await page.getByRole('button', { name: 'Back to the factory' }).click(); + } + await testInfo.attach('scaling-comparison', { body: JSON.stringify(reports, null, 2), contentType: 'application/json' }); + expect(errors).toEqual([]); +});