Compare commits
25 Commits
claude/arc
...
bl/assets-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f81ea488d | ||
|
|
29b0618c5e | ||
|
|
adf17e516b | ||
|
|
070a5f59fe | ||
|
|
7864e780e7 | ||
|
|
db1257fdb3 | ||
|
|
7e7e2d5647 | ||
|
|
dabfc6521e | ||
|
|
2f9431c6dd | ||
|
|
62979e3818 | ||
|
|
6e249f2e05 | ||
|
|
a1c46d7086 | ||
|
|
dd89b74ca5 | ||
|
|
e809d74192 | ||
|
|
47c9a027a7 | ||
|
|
6fbc5723bd | ||
|
|
db6e5062f2 | ||
|
|
6da5d26980 | ||
|
|
9b6b762a97 | ||
|
|
00c8c11288 | ||
|
|
668f7e48e7 | ||
|
|
3de387429a | ||
|
|
08b1199265 | ||
|
|
98a9facc7d | ||
|
|
8b1cf594d1 |
99
.claude/skills/contain-audit/SKILL.md
Normal file
@@ -0,0 +1,99 @@
|
||||
---
|
||||
name: contain-audit
|
||||
description: 'Detect DOM elements where CSS contain:layout+style would improve rendering performance. Runs a Playwright-based audit on a large workflow, scores candidates by subtree size and sizing constraints, measures performance impact, and generates a ranked report.'
|
||||
---
|
||||
|
||||
# CSS Containment Audit
|
||||
|
||||
Automatically finds DOM elements where adding `contain: layout style` would reduce browser recalculation overhead.
|
||||
|
||||
## What It Does
|
||||
|
||||
1. Loads a large workflow (245 nodes) in a real browser
|
||||
2. Walks the DOM tree and scores every element as a containment candidate
|
||||
3. For each high-scoring candidate, applies `contain: layout style` via JavaScript
|
||||
4. Measures rendering performance (style recalcs, layouts, task duration) before and after
|
||||
5. Takes before/after screenshots to detect visual breakage
|
||||
6. Generates a ranked report with actionable recommendations
|
||||
|
||||
## When to Use
|
||||
|
||||
- After adding new Vue components to the node rendering pipeline
|
||||
- When investigating rendering performance on large workflows
|
||||
- Before and after refactoring node DOM structure
|
||||
- As part of periodic performance audits
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Start the dev server first
|
||||
pnpm dev &
|
||||
|
||||
# Run the audit (uses the @audit tag, not included in normal CI runs)
|
||||
pnpm exec playwright test browser_tests/tests/containAudit.spec.ts --project=audit
|
||||
|
||||
# View the HTML report
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## How to Read Results
|
||||
|
||||
The audit outputs a table to the console:
|
||||
|
||||
```text
|
||||
CSS Containment Audit Results
|
||||
=======================================================
|
||||
Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual
|
||||
1 | [data-testid="node-inner-wrap"] | 18 | 72 | -34% | -12% | OK
|
||||
2 | .node-body | 12 | 48 | -8% | -3% | OK
|
||||
3 | .node-header | 4 | 16 | +1% | 0% | OK
|
||||
```
|
||||
|
||||
- **Subtree**: Number of descendant elements (higher = more to skip)
|
||||
- **Score**: Composite heuristic score (subtree size x sizing constraint bonus)
|
||||
- **DRecalcs / DLayouts**: Change in style recalcs / layout counts vs baseline (negative = improvement)
|
||||
- **Visual**: OK if no pixel change, DIFF if screenshot differs (may include subpixel noise — verify manually)
|
||||
|
||||
## Candidate Scoring
|
||||
|
||||
An element is a good containment candidate when:
|
||||
|
||||
1. **Large subtree** -- many descendants that the browser can skip recalculating
|
||||
2. **Externally constrained size** -- width/height determined by CSS variables, flex, or explicit values (not by content)
|
||||
3. **No existing containment** -- `contain` is not already applied
|
||||
4. **Not a leaf** -- has at least a few child elements
|
||||
|
||||
Elements that should NOT get containment:
|
||||
|
||||
- Elements whose children overflow visually beyond bounds (e.g., absolute-positioned overlays with negative inset)
|
||||
- Elements whose height is determined by content and affects sibling layout
|
||||
- Very small subtrees (overhead of containment context outweighs benefit)
|
||||
|
||||
## Limitations
|
||||
|
||||
- Cannot fully guarantee `contain` safety -- visual review of screenshots is required
|
||||
- Performance measurements have natural variance; run multiple times for confidence
|
||||
- Only tests idle and pan scenarios; widget interactions may differ
|
||||
- The audit modifies styles at runtime via JS, which doesn't account for Tailwind purging or build-time optimizations
|
||||
|
||||
## Example PR
|
||||
|
||||
[#9946 — fix: add CSS contain:layout contain:style to node inner wrapper](https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946)
|
||||
|
||||
This PR added `contain-layout contain-style` to the node inner wrapper div in `LGraphNode.vue`. The audit tool would have flagged this element as a high-scoring candidate because:
|
||||
|
||||
- **Large subtree** (18+ descendants: header, slots, widgets, content, badges)
|
||||
- **Externally constrained size** (`w-(--node-width)`, `flex-1` — dimensions set by CSS variables and flex parent)
|
||||
- **Natural isolation boundary** between frequently-changing content (widgets) and infrequently-changing overlays (selection outlines, borders)
|
||||
|
||||
The actual change was a single line: adding `'contain-layout contain-style'` to the inner wrapper's class list at `src/renderer/extensions/vueNodes/components/LGraphNode.vue:79`.
|
||||
|
||||
## Reference
|
||||
|
||||
| Resource | Path |
|
||||
| ----------------- | ------------------------------------------------------- |
|
||||
| Audit test | `browser_tests/tests/containAudit.spec.ts` |
|
||||
| PerformanceHelper | `browser_tests/fixtures/helpers/PerformanceHelper.ts` |
|
||||
| Perf tests | `browser_tests/tests/performance.spec.ts` |
|
||||
| Large workflow | `browser_tests/assets/large-graph-workflow.json` |
|
||||
| Example PR | https://github.com/Comfy-Org/ComfyUI_frontend/pull/9946 |
|
||||
1
.gitignore
vendored
@@ -66,7 +66,6 @@ dist.zip
|
||||
|
||||
/temp/
|
||||
/tmp/
|
||||
.superpowers/
|
||||
|
||||
# Generated JSON Schemas
|
||||
/schemas/
|
||||
|
||||
@@ -208,7 +208,7 @@ See @docs/testing/\*.md for detailed patterns.
|
||||
3. Keep your module mocks contained
|
||||
Do not use global mutable state within the test file
|
||||
Use `vi.hoisted()` if necessary to allow for per-test Arrange phase manipulation of deeper mock state
|
||||
4. For Component testing, use [Vue Test Utils](https://test-utils.vuejs.org/) and especially follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
4. For Component testing, prefer [@testing-library/vue](https://testing-library.com/docs/vue-testing-library/intro/) with `@testing-library/user-event` for user-centric, behavioral tests. [Vue Test Utils](https://test-utils.vuejs.org/) is also accepted, especially for tests that need direct access to the component wrapper (e.g., `findComponent`, `emitted()`). Follow the advice [about making components easy to test](https://test-utils.vuejs.org/guide/essentials/easy-to-test.html)
|
||||
5. Aim for behavioral coverage of critical and new features
|
||||
|
||||
### Playwright / Browser / E2E Tests
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codebase Caverns — ComfyUI Architecture Adventure</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="A prestige-driven architecture adventure game. Discover problems, learn patterns, make decisions, and watch the consequences unfold."
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"name": "@comfyorg/architecture-adventure",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"nx": {
|
||||
"tags": [
|
||||
"scope:docs",
|
||||
"type:app"
|
||||
],
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"command": "vite build --config apps/architecture-adventure/vite.config.ts"
|
||||
},
|
||||
"outputs": [
|
||||
"{projectRoot}/dist"
|
||||
]
|
||||
},
|
||||
"preview": {
|
||||
"executor": "nx:run-commands",
|
||||
"continuous": true,
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "vite preview"
|
||||
}
|
||||
},
|
||||
"typecheck": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"options": {
|
||||
"cwd": "apps/architecture-adventure",
|
||||
"command": "tsc --noEmit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
function main(): void {
|
||||
const app = document.getElementById('app')
|
||||
if (!app) throw new Error('Missing #app element')
|
||||
app.textContent = 'Codebase Caverns v2 — Loading...'
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
root: projectRoot,
|
||||
base: './',
|
||||
build: {
|
||||
target: 'es2022',
|
||||
outDir: 'dist',
|
||||
assetsInlineLimit: 1_000_000,
|
||||
cssCodeSplit: false,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src')
|
||||
}
|
||||
}
|
||||
})
|
||||
48
browser_tests/assets/widgets/painter_widget.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Painter",
|
||||
"pos": [50, 50],
|
||||
"size": [450, 550],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Painter"
|
||||
},
|
||||
"widgets_values": ["", 512, 512, "#000000"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import { ContextMenu } from './components/ContextMenu'
|
||||
import { SettingDialog } from './components/SettingDialog'
|
||||
import { BottomPanel } from './components/BottomPanel'
|
||||
import {
|
||||
AssetsSidebarTab,
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { AssetsHelper } from './helpers/AssetsHelper'
|
||||
import { CanvasHelper } from './helpers/CanvasHelper'
|
||||
import { PerformanceHelper } from './helpers/PerformanceHelper'
|
||||
import { QueueHelper } from './helpers/QueueHelper'
|
||||
@@ -55,6 +57,7 @@ class ComfyPropertiesPanel {
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
private _assetsTab: AssetsSidebarTab | null = null
|
||||
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
|
||||
private _workflowsTab: WorkflowsSidebarTab | null = null
|
||||
private _topbar: Topbar | null = null
|
||||
@@ -78,6 +81,11 @@ class ComfyMenu {
|
||||
return this._nodeLibraryTab
|
||||
}
|
||||
|
||||
get assetsTab() {
|
||||
this._assetsTab ??= new AssetsSidebarTab(this.page)
|
||||
return this._assetsTab
|
||||
}
|
||||
|
||||
get workflowsTab() {
|
||||
this._workflowsTab ??= new WorkflowsSidebarTab(this.page)
|
||||
return this._workflowsTab
|
||||
@@ -192,6 +200,7 @@ export class ComfyPage {
|
||||
public readonly command: CommandHelper
|
||||
public readonly bottomPanel: BottomPanel
|
||||
public readonly perf: PerformanceHelper
|
||||
public readonly assets: AssetsHelper
|
||||
public readonly queue: QueueHelper
|
||||
|
||||
/** Worker index to test user ID */
|
||||
@@ -238,6 +247,7 @@ export class ComfyPage {
|
||||
this.command = new CommandHelper(page)
|
||||
this.bottomPanel = new BottomPanel(page)
|
||||
this.perf = new PerformanceHelper(page)
|
||||
this.assets = new AssetsHelper(page)
|
||||
this.queue = new QueueHelper(page)
|
||||
}
|
||||
|
||||
@@ -452,12 +462,13 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
const isPerf = testInfo.tags.includes('@perf')
|
||||
if (isPerf) await comfyPage.perf.init()
|
||||
const needsPerf =
|
||||
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')
|
||||
if (needsPerf) await comfyPage.perf.init()
|
||||
|
||||
await use(comfyPage)
|
||||
|
||||
if (isPerf) await comfyPage.perf.dispose()
|
||||
if (needsPerf) await comfyPage.perf.dispose()
|
||||
},
|
||||
comfyMouse: async ({ comfyPage }, use) => {
|
||||
const comfyMouse = new ComfyMouse(comfyPage)
|
||||
|
||||
@@ -168,3 +168,167 @@ export class WorkflowsSidebarTab extends SidebarTab {
|
||||
.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class AssetsSidebarTab extends SidebarTab {
|
||||
constructor(public override readonly page: Page) {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
get root() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
|
||||
get importedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
)
|
||||
}
|
||||
|
||||
get searchInput() {
|
||||
return this.root.getByPlaceholder(/Search Assets/i)
|
||||
}
|
||||
|
||||
get viewSettingsButton() {
|
||||
return this.root.getByLabel('View settings')
|
||||
}
|
||||
|
||||
get listViewButton() {
|
||||
return this.page.getByRole('button', { name: 'List view' })
|
||||
}
|
||||
|
||||
get gridViewButton() {
|
||||
return this.page.getByRole('button', { name: 'Grid view' })
|
||||
}
|
||||
|
||||
get backButton() {
|
||||
return this.page.getByRole('button', { name: 'Back to all assets' })
|
||||
}
|
||||
|
||||
get copyJobIdButton() {
|
||||
return this.page.getByRole('button', { name: 'Copy job ID' })
|
||||
}
|
||||
|
||||
get previewDialog() {
|
||||
return this.page.getByRole('dialog', { name: 'Gallery' })
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.root.getByRole('button', { name: /Assets Selected:/ })
|
||||
}
|
||||
|
||||
get downloadSelectionButton() {
|
||||
return this.page.getByRole('button', { name: 'Download' })
|
||||
}
|
||||
|
||||
get deleteSelectionButton() {
|
||||
return this.page.getByRole('button', { name: 'Delete' })
|
||||
}
|
||||
|
||||
emptyStateTitle(title: string) {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
previewImage(filename: string) {
|
||||
return this.previewDialog.getByRole('img', { name: filename })
|
||||
}
|
||||
|
||||
asset(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
return this.root.getByRole('button', {
|
||||
name: new RegExp(`^${escaped} - .* asset$`)
|
||||
})
|
||||
}
|
||||
|
||||
contextMenuAction(name: string) {
|
||||
return this.page.getByRole('button', { name })
|
||||
}
|
||||
|
||||
async showGenerated() {
|
||||
await this.generatedTab.click()
|
||||
}
|
||||
|
||||
async showImported() {
|
||||
await this.importedTab.click()
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
await this.searchInput.fill(query)
|
||||
}
|
||||
|
||||
async switchToListView() {
|
||||
await this.viewSettingsButton.click()
|
||||
await this.listViewButton.click()
|
||||
}
|
||||
|
||||
async switchToGridView() {
|
||||
await this.viewSettingsButton.click()
|
||||
await this.gridViewButton.click()
|
||||
}
|
||||
|
||||
async openContextMenuForAsset(name: string) {
|
||||
await this.asset(name).click({ button: 'right' })
|
||||
}
|
||||
|
||||
async runContextMenuAction(assetName: string, actionName: string) {
|
||||
await this.openContextMenuForAsset(assetName)
|
||||
await this.contextMenuAction(actionName).click()
|
||||
}
|
||||
|
||||
async openAssetPreview(name: string) {
|
||||
const asset = this.asset(name)
|
||||
await asset.hover()
|
||||
|
||||
const zoomButton = asset.getByLabel('Zoom in')
|
||||
if (await zoomButton.isVisible().catch(() => false)) {
|
||||
await zoomButton.click()
|
||||
return
|
||||
}
|
||||
|
||||
await asset.dblclick()
|
||||
}
|
||||
|
||||
async openOutputFolder(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
|
||||
await this.page
|
||||
.getByRole('button', { name: 'Back to all assets' })
|
||||
.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async toggleStack(name: string) {
|
||||
await this.asset(name)
|
||||
.getByRole('button', { name: 'See more outputs' })
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectAssets(names: string[]) {
|
||||
if (names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.asset(names[0]).click()
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
for (const name of names.slice(1)) {
|
||||
await this.asset(name).click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override async open() {
|
||||
await super.open()
|
||||
await this.root.waitFor({ state: 'visible' })
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,29 @@ export class AppModeHelper {
|
||||
.getByTestId(TestIds.builder.widgetActionsMenu)
|
||||
}
|
||||
|
||||
/** The builder footer nav containing save/navigation buttons. */
|
||||
private get builderFooterNav(): Locator {
|
||||
return this.page
|
||||
.getByRole('button', { name: 'Exit app builder' })
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
/** Get a button in the builder footer by its accessible name. */
|
||||
getFooterButton(name: string | RegExp): Locator {
|
||||
return this.builderFooterNav.getByRole('button', { name })
|
||||
}
|
||||
|
||||
/** Click the save/save-as button in the builder footer. */
|
||||
async clickSave() {
|
||||
await this.getFooterButton(/^Save/).first().click()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** The "Opens as" popover tab above the builder footer. */
|
||||
get opensAsPopover(): Locator {
|
||||
return this.page.getByTestId(TestIds.builder.opensAs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a widget by clicking its popover trigger, selecting "Rename",
|
||||
* and filling in the dialog.
|
||||
|
||||
361
browser_tests/fixtures/helpers/AssetsHelper.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'node:path'
|
||||
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
import type { ResultItemType, TaskOutput } from '../../../src/schemas/apiSchema'
|
||||
|
||||
import { JobsApiMock, type SeededJob } from './JobsApiMock'
|
||||
import { getMimeType } from './mimeTypeUtil'
|
||||
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
const viewRoutePattern = /\/api\/view(?:\?.*)?$/
|
||||
const helperDir = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
type SeededAssetFile = {
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
textContent?: string
|
||||
}
|
||||
|
||||
export type ImportedAssetSeed = {
|
||||
name: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
|
||||
export type GeneratedAssetOutputSeed = {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
mediaType?: 'images' | 'video' | 'audio'
|
||||
subfolder?: string
|
||||
type?: ResultItemType
|
||||
}
|
||||
|
||||
export type GeneratedJobSeed = {
|
||||
jobId: string
|
||||
outputs: [GeneratedAssetOutputSeed, ...GeneratedAssetOutputSeed[]]
|
||||
createdAt?: string
|
||||
createTime?: number
|
||||
executionStartTime?: number
|
||||
executionEndTime?: number
|
||||
workflowId?: string | null
|
||||
workflow?: unknown
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
function getFixturePath(relativePath: string): string {
|
||||
return path.resolve(helperDir, '../../assets', relativePath)
|
||||
}
|
||||
|
||||
function defaultFileFor(filename: string): SeededAssetFile {
|
||||
const name = filename.toLowerCase()
|
||||
|
||||
if (name.endsWith('.png')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow_itxt.png'),
|
||||
contentType: 'image/png'
|
||||
}
|
||||
}
|
||||
|
||||
if (name.endsWith('.webp')) {
|
||||
return {
|
||||
filePath: getFixturePath('example.webp'),
|
||||
contentType: 'image/webp'
|
||||
}
|
||||
}
|
||||
|
||||
if (name.endsWith('.webm')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.webm'),
|
||||
contentType: 'video/webm'
|
||||
}
|
||||
}
|
||||
|
||||
if (name.endsWith('.mp4')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.mp4'),
|
||||
contentType: 'video/mp4'
|
||||
}
|
||||
}
|
||||
|
||||
if (name.endsWith('.glb')) {
|
||||
return {
|
||||
filePath: getFixturePath('workflowInMedia/workflow.glb'),
|
||||
contentType: 'model/gltf-binary'
|
||||
}
|
||||
}
|
||||
|
||||
if (name.endsWith('.json')) {
|
||||
return {
|
||||
textContent: JSON.stringify({ mocked: true }, null, 2),
|
||||
contentType: 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: 'mocked asset content',
|
||||
contentType: getMimeType(filename)
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutputSeed(
|
||||
output: GeneratedAssetOutputSeed
|
||||
): GeneratedAssetOutputSeed {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
|
||||
return {
|
||||
mediaType: 'images',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
...output,
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType
|
||||
}
|
||||
}
|
||||
|
||||
function buildTaskOutput(
|
||||
jobSeed: GeneratedJobSeed,
|
||||
outputs: GeneratedAssetOutputSeed[]
|
||||
): TaskOutput {
|
||||
const nodeId = jobSeed.nodeId ?? '5'
|
||||
|
||||
return {
|
||||
[nodeId]: {
|
||||
[outputs[0].mediaType ?? 'images']: outputs.map((output) => ({
|
||||
filename: output.filename,
|
||||
subfolder: output.subfolder ?? '',
|
||||
type: output.type ?? 'output',
|
||||
display_name: output.displayName
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeededJob(jobSeed: GeneratedJobSeed): SeededJob {
|
||||
const outputs = jobSeed.outputs.map(normalizeOutputSeed)
|
||||
const preview = outputs[0]
|
||||
const createTime =
|
||||
jobSeed.createTime ??
|
||||
new Date(jobSeed.createdAt ?? '2026-03-27T12:00:00.000Z').getTime()
|
||||
const executionStartTime = jobSeed.executionStartTime ?? createTime
|
||||
const executionEndTime = jobSeed.executionEndTime ?? createTime + 2_000
|
||||
|
||||
const listItem: RawJobListItem = {
|
||||
id: jobSeed.jobId,
|
||||
status: 'completed',
|
||||
create_time: createTime,
|
||||
execution_start_time: executionStartTime,
|
||||
execution_end_time: executionEndTime,
|
||||
preview_output: {
|
||||
filename: preview.filename,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
nodeId: jobSeed.nodeId ?? '5',
|
||||
mediaType: preview.mediaType ?? 'images',
|
||||
display_name: preview.displayName
|
||||
},
|
||||
outputs_count: outputs.length,
|
||||
workflow_id: jobSeed.workflowId ?? null
|
||||
}
|
||||
|
||||
const detail: JobDetail = {
|
||||
...listItem,
|
||||
workflow: jobSeed.workflow,
|
||||
outputs: buildTaskOutput(jobSeed, outputs),
|
||||
update_time: executionEndTime
|
||||
}
|
||||
|
||||
return { listItem, detail }
|
||||
}
|
||||
|
||||
export class AssetsHelper {
|
||||
private readonly jobsApiMock: JobsApiMock
|
||||
private inputFilesRouteHandler: ((route: Route) => Promise<void>) | null =
|
||||
null
|
||||
private viewRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private generatedJobs: GeneratedJobSeed[] = []
|
||||
private importedFiles: ImportedAssetSeed[] = []
|
||||
private seededFiles = new Map<string, SeededAssetFile>()
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.jobsApiMock = new JobsApiMock(page)
|
||||
}
|
||||
|
||||
generatedImage(
|
||||
options: Partial<Omit<GeneratedJobSeed, 'outputs'>> & {
|
||||
filename: string
|
||||
displayName?: string
|
||||
filePath?: string
|
||||
contentType?: string
|
||||
}
|
||||
): GeneratedJobSeed {
|
||||
const {
|
||||
filename,
|
||||
displayName,
|
||||
filePath,
|
||||
contentType,
|
||||
jobId = `job-${filename.replace(/\W+/g, '-').toLowerCase()}`,
|
||||
...rest
|
||||
} = options
|
||||
|
||||
return {
|
||||
jobId,
|
||||
outputs: [
|
||||
{
|
||||
filename,
|
||||
displayName,
|
||||
filePath,
|
||||
contentType,
|
||||
mediaType: 'images'
|
||||
}
|
||||
],
|
||||
...rest
|
||||
}
|
||||
}
|
||||
|
||||
importedImage(options: ImportedAssetSeed): ImportedAssetSeed {
|
||||
return { ...options }
|
||||
}
|
||||
|
||||
async workflowContainerFromFixture(
|
||||
relativePath: string = 'default.json'
|
||||
): Promise<GeneratedJobSeed['workflow']> {
|
||||
const workflow = JSON.parse(
|
||||
await readFile(getFixturePath(relativePath), 'utf-8')
|
||||
)
|
||||
|
||||
return {
|
||||
extra_data: {
|
||||
extra_pnginfo: {
|
||||
workflow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async seedAssets({
|
||||
generated = [],
|
||||
imported = []
|
||||
}: {
|
||||
generated?: GeneratedJobSeed[]
|
||||
imported?: ImportedAssetSeed[]
|
||||
}): Promise<void> {
|
||||
this.generatedJobs = [...generated]
|
||||
this.importedFiles = [...imported]
|
||||
this.seededFiles = new Map()
|
||||
|
||||
for (const job of this.generatedJobs) {
|
||||
for (const output of job.outputs) {
|
||||
const fallback = defaultFileFor(output.filename)
|
||||
this.seededFiles.set(output.filename, {
|
||||
filePath: output.filePath ?? fallback.filePath,
|
||||
contentType: output.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of this.importedFiles) {
|
||||
const fallback = defaultFileFor(asset.name)
|
||||
this.seededFiles.set(asset.name, {
|
||||
filePath: asset.filePath ?? fallback.filePath,
|
||||
contentType: asset.contentType ?? fallback.contentType,
|
||||
textContent: fallback.textContent
|
||||
})
|
||||
}
|
||||
|
||||
await this.jobsApiMock.seedJobs(this.generatedJobs.map(buildSeededJob))
|
||||
await this.ensureInputFilesRoute()
|
||||
await this.ensureViewRoute()
|
||||
}
|
||||
|
||||
async mockEmptyState(): Promise<void> {
|
||||
await this.seedAssets({ generated: [], imported: [] })
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.generatedJobs = []
|
||||
this.importedFiles = []
|
||||
this.seededFiles.clear()
|
||||
|
||||
await this.jobsApiMock.clearMocks()
|
||||
|
||||
if (this.inputFilesRouteHandler) {
|
||||
await this.page.unroute(
|
||||
inputFilesRoutePattern,
|
||||
this.inputFilesRouteHandler
|
||||
)
|
||||
this.inputFilesRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.viewRouteHandler) {
|
||||
await this.page.unroute(viewRoutePattern, this.viewRouteHandler)
|
||||
this.viewRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInputFilesRoute(): Promise<void> {
|
||||
if (this.inputFilesRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.inputFilesRouteHandler = async (route: Route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(this.importedFiles.map((asset) => asset.name))
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(inputFilesRoutePattern, this.inputFilesRouteHandler)
|
||||
}
|
||||
|
||||
private async ensureViewRoute(): Promise<void> {
|
||||
if (this.viewRouteHandler) {
|
||||
return
|
||||
}
|
||||
|
||||
this.viewRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const filename = url.searchParams.get('filename')
|
||||
|
||||
if (!filename) {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Missing filename' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const seededFile =
|
||||
this.seededFiles.get(filename) ?? defaultFileFor(filename)
|
||||
|
||||
if (seededFile.filePath) {
|
||||
const body = await readFile(seededFile.filePath)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: seededFile.contentType ?? getMimeType(filename),
|
||||
body: seededFile.textContent ?? ''
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(viewRoutePattern, this.viewRouteHandler)
|
||||
}
|
||||
}
|
||||
194
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
JobDetail,
|
||||
RawJobListItem
|
||||
} from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
|
||||
export type SeededJob = {
|
||||
listItem: RawJobListItem
|
||||
detail: JobDetail
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return total
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseOffset(url: URL): number {
|
||||
const value = Number(url.searchParams.get('offset'))
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getExecutionDuration(job: RawJobListItem): number {
|
||||
const start = job.execution_start_time ?? 0
|
||||
const end = job.execution_end_time ?? 0
|
||||
return end - start
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private seededJobs: SeededJob[] = []
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async seedJobs(jobs: SeededJob[]): Promise<void> {
|
||||
this.seededJobs = [...jobs]
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clearMocks(): Promise<void> {
|
||||
this.seededJobs = []
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
const workflowId = url.searchParams.get('workflow_id')
|
||||
const sortBy = url.searchParams.get('sort_by')
|
||||
const sortOrder = url.searchParams.get('sort_order') === 'asc' ? 1 : -1
|
||||
|
||||
let filteredJobs = this.seededJobs.map(({ listItem }) => listItem)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
if (workflowId) {
|
||||
filteredJobs = filteredJobs.filter(
|
||||
(job) => job.workflow_id === workflowId
|
||||
)
|
||||
}
|
||||
|
||||
filteredJobs.sort((left, right) => {
|
||||
const leftValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(left)
|
||||
: left.create_time
|
||||
const rightValue =
|
||||
sortBy === 'execution_duration'
|
||||
? getExecutionDuration(right)
|
||||
: right.create_time
|
||||
|
||||
return (leftValue - rightValue) * sortOrder
|
||||
})
|
||||
|
||||
const offset = parseOffset(url)
|
||||
const total = filteredJobs.length
|
||||
const limit = parseLimit(url, total)
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = route
|
||||
.request()
|
||||
.url()
|
||||
.split('/api/jobs/')[1]
|
||||
?.split('?')[0]
|
||||
const job = jobId
|
||||
? this.seededJobs.find(({ listItem }) => listItem.id === jobId)
|
||||
: undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const requestBody = route.request().postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.seededJobs = this.seededJobs.filter(
|
||||
({ listItem }) =>
|
||||
listItem.status === 'pending' || listItem.status === 'in_progress'
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
const deletedIds = new Set(requestBody.delete)
|
||||
this.seededJobs = this.seededJobs.filter(
|
||||
({ listItem }) => !deletedIds.has(listItem.id)
|
||||
)
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@ export class KeyboardHelper {
|
||||
keyToPress: string,
|
||||
locator: Locator | null = this.canvas
|
||||
): Promise<void> {
|
||||
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
||||
const target = locator ?? this.page.keyboard
|
||||
await target.press(`Control+${keyToPress}`)
|
||||
await target.press(`${modifier}+${keyToPress}`)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@ export const TestIds = {
|
||||
main: 'graph-canvas',
|
||||
contextMenu: 'canvas-context-menu',
|
||||
toggleMinimapButton: 'toggle-minimap-button',
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button'
|
||||
toggleLinkVisibilityButton: 'toggle-link-visibility-button',
|
||||
zoomControlsButton: 'zoom-controls-button',
|
||||
zoomInAction: 'zoom-in-action',
|
||||
zoomOutAction: 'zoom-out-action',
|
||||
zoomToFitAction: 'zoom-to-fit-action',
|
||||
zoomPercentageInput: 'zoom-percentage-input'
|
||||
},
|
||||
dialogs: {
|
||||
settings: 'settings-dialog',
|
||||
@@ -29,6 +34,8 @@ export const TestIds = {
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
errorOverlayMessages: 'error-overlay-messages',
|
||||
runtimeErrorPanel: 'runtime-error-panel',
|
||||
missingNodeCard: 'missing-node-card',
|
||||
errorCardFindOnGithub: 'error-card-find-on-github',
|
||||
@@ -72,7 +79,8 @@ export const TestIds = {
|
||||
builder: {
|
||||
ioItem: 'builder-io-item',
|
||||
ioItemTitle: 'builder-io-item-title',
|
||||
widgetActionsMenu: 'widget-actions-menu'
|
||||
widgetActionsMenu: 'widget-actions-menu',
|
||||
opensAs: 'builder-opens-as'
|
||||
},
|
||||
breadcrumb: {
|
||||
subgraph: 'subgraph-breadcrumb'
|
||||
|
||||
118
browser_tests/helpers/builderTestUtils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
import { fitToViewInstant } from './fitToView'
|
||||
import { getPromotedWidgetNames } from './promotedWidgets'
|
||||
|
||||
/** Click the first SaveImage/PreviewImage node on the canvas. */
|
||||
async function selectOutputNode(comfyPage: ComfyPage) {
|
||||
const { page } = comfyPage
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/** Center on a node and click its first widget to select it as input. */
|
||||
async function selectInputWidget(comfyPage: ComfyPage, node: NodeReference) {
|
||||
const { page } = comfyPage
|
||||
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await node.centerOnNode()
|
||||
|
||||
const widgetRef = await node.getWidget(0)
|
||||
const widgetPos = await widgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
await page.mouse.click(widgetPos.x, widgetPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter builder on the default workflow and select I/O.
|
||||
*
|
||||
* Loads the default workflow, optionally transforms it (e.g. convert a node
|
||||
* to subgraph), then enters builder mode and selects inputs + outputs.
|
||||
*
|
||||
* @param comfyPage - The page fixture.
|
||||
* @param getInputNode - Returns the node to click for input selection.
|
||||
* Receives the KSampler node ref and can transform the graph before
|
||||
* returning the target node. Defaults to using KSampler directly.
|
||||
* @returns The node used for input selection.
|
||||
*/
|
||||
export async function setupBuilder(
|
||||
comfyPage: ComfyPage,
|
||||
getInputNode?: (ksampler: NodeReference) => Promise<NodeReference>
|
||||
): Promise<NodeReference> {
|
||||
const { appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
const inputNode = getInputNode ? await getInputNode(ksampler) : ksampler
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
await selectInputWidget(comfyPage, inputNode)
|
||||
|
||||
await appMode.goToOutputs()
|
||||
await selectOutputNode(comfyPage)
|
||||
|
||||
return inputNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the KSampler to a subgraph, then enter builder with I/O selected.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
export async function setupSubgraphBuilder(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<NodeReference> {
|
||||
return setupBuilder(comfyPage, async (ksampler) => {
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const promotedNames = await getPromotedWidgetNames(
|
||||
comfyPage,
|
||||
String(subgraphNode.id)
|
||||
)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
return subgraphNode
|
||||
})
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
export async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
@@ -1,89 +1,11 @@
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
import { getPromotedWidgetNames } from '../helpers/promotedWidgets'
|
||||
|
||||
/**
|
||||
* Convert the KSampler (id 3) in the default workflow to a subgraph,
|
||||
* enter builder, select the promoted seed widget as input and
|
||||
* SaveImage/PreviewImage as output.
|
||||
*
|
||||
* Returns the subgraph node reference for further interaction.
|
||||
*/
|
||||
async function setupSubgraphBuilder(comfyPage: ComfyPage) {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
|
||||
await ksampler.click('title')
|
||||
const subgraphNode = await ksampler.convertToSubgraph()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const subgraphNodeId = String(subgraphNode.id)
|
||||
const promotedNames = await getPromotedWidgetNames(comfyPage, subgraphNodeId)
|
||||
expect(promotedNames).toContain('seed')
|
||||
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
|
||||
await comfyPage.canvasOps.setScale(1)
|
||||
await subgraphNode.centerOnNode()
|
||||
|
||||
// Click the promoted seed widget on the canvas to select it
|
||||
const seedWidgetRef = await subgraphNode.getWidget(0)
|
||||
const seedPos = await seedWidgetRef.getPosition()
|
||||
const titleHeight = await page.evaluate(
|
||||
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
|
||||
)
|
||||
|
||||
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select an output node
|
||||
await appMode.goToOutputs()
|
||||
|
||||
const saveImageNodeId = await page.evaluate(() =>
|
||||
String(
|
||||
window.app!.rootGraph.nodes.find(
|
||||
(n: { type?: string }) =>
|
||||
n.type === 'SaveImage' || n.type === 'PreviewImage'
|
||||
)?.id
|
||||
)
|
||||
)
|
||||
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
|
||||
await saveImageRef.centerOnNode()
|
||||
|
||||
// Node is centered on screen, so click the canvas center
|
||||
const canvasBox = await page.locator('#graph-canvas').boundingBox()
|
||||
if (!canvasBox) throw new Error('Canvas not found')
|
||||
await page.mouse.click(
|
||||
canvasBox.x + canvasBox.width / 2,
|
||||
canvasBox.y + canvasBox.height / 2
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return subgraphNode
|
||||
}
|
||||
|
||||
/** Save the workflow, reopen it, and enter app mode. */
|
||||
async function saveAndReopenInAppMode(
|
||||
comfyPage: ComfyPage,
|
||||
workflowName: string
|
||||
) {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(workflowName).dblclick()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.appMode.toggleAppMode()
|
||||
}
|
||||
import {
|
||||
saveAndReopenInAppMode,
|
||||
setupSubgraphBuilder
|
||||
} from '../helpers/builderTestUtils'
|
||||
|
||||
test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
251
browser_tests/tests/builderSaveFlow.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { setupSubgraphBuilder } from '../helpers/builderTestUtils'
|
||||
import { fitToViewInstant } from '../helpers/fitToView'
|
||||
|
||||
test.describe('Builder save flow', { tag: ['@ui', '@subgraph'] }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.api.serverFeatureFlags.value = {
|
||||
...window.app!.api.serverFeatureFlags.value,
|
||||
linear_toggle_enabled: true
|
||||
}
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.AppBuilder.VueNodeSwitchDismissed',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Save as dialog appears for unsaved workflow', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
// The save-as dialog should appear with filename input and view type selection
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
await expect(dialog.getByRole('textbox')).toBeVisible()
|
||||
await expect(dialog.getByText('Save as')).toBeVisible()
|
||||
|
||||
// View type radio group should be present
|
||||
const radioGroup = dialog.getByRole('radiogroup')
|
||||
await expect(radioGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save as dialog allows entering filename and saving', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const workflowName = `${Date.now()} builder-save-test`
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill(workflowName)
|
||||
|
||||
// Save button should be enabled now
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeEnabled()
|
||||
await saveButton.click()
|
||||
|
||||
// Success dialog should appear
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
})
|
||||
|
||||
test('Save as dialog disables save when filename is empty', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Clear the filename input
|
||||
const input = dialog.getByRole('textbox')
|
||||
await input.fill('')
|
||||
|
||||
// Save button should be disabled
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Builder step navigation works correctly', async ({ comfyPage }) => {
|
||||
const { appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Should start at outputs (we ended there in setup)
|
||||
// Navigate to inputs
|
||||
await appMode.goToInputs()
|
||||
|
||||
// Back button should be disabled on first step
|
||||
const backButton = appMode.getFooterButton('Back')
|
||||
await expect(backButton).toBeDisabled()
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = appMode.getFooterButton('Next')
|
||||
await expect(nextButton).toBeEnabled()
|
||||
|
||||
// Navigate forward
|
||||
await appMode.next()
|
||||
|
||||
// Back button should now be enabled
|
||||
await expect(backButton).toBeEnabled()
|
||||
|
||||
// Navigate to preview (last step)
|
||||
await appMode.next()
|
||||
|
||||
// Next button should be disabled on last step
|
||||
await expect(nextButton).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Escape key exits builder mode', async ({ comfyPage }) => {
|
||||
const { page } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
// Verify builder toolbar is visible
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
// Press Escape
|
||||
await page.keyboard.press('Escape')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Builder toolbar should be gone
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Exit builder button exits builder mode', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
|
||||
const toolbar = page.getByRole('navigation', { name: 'App Builder' })
|
||||
await expect(toolbar).toBeVisible()
|
||||
|
||||
await appMode.exitBuilder()
|
||||
|
||||
await expect(toolbar).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Save button directly saves for previously saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-direct-save`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Now click save again — should save directly
|
||||
await appMode.clickSave()
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 })
|
||||
await expect(appMode.getFooterButton(/^Save$/)).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Split button chevron opens save-as for saved workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
|
||||
// First save via builder save-as to make it non-temporary
|
||||
await appMode.clickSave()
|
||||
const saveAsDialog = page.getByRole('dialog')
|
||||
await expect(saveAsDialog).toBeVisible({ timeout: 5000 })
|
||||
const workflowName = `${Date.now()} builder-split-btn`
|
||||
await saveAsDialog.getByRole('textbox').fill(workflowName)
|
||||
await saveAsDialog.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Dismiss the success dialog
|
||||
const successDialog = page.getByRole('dialog')
|
||||
await expect(successDialog.getByText('Successfully saved')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await successDialog.getByText('Close', { exact: true }).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Click the chevron dropdown trigger
|
||||
const chevronButton = appMode.getFooterButton('Save as')
|
||||
await chevronButton.click()
|
||||
|
||||
// "Save as" menu item should appear
|
||||
const menuItem = page.getByRole('menuitem', { name: 'Save as' })
|
||||
await expect(menuItem).toBeVisible({ timeout: 5000 })
|
||||
await menuItem.click()
|
||||
|
||||
// Save-as dialog should appear
|
||||
const newSaveAsDialog = page.getByRole('dialog')
|
||||
await expect(newSaveAsDialog.getByText('Save as')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
await expect(newSaveAsDialog.getByRole('textbox')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Connect output popover appears when no outputs selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await fitToViewInstant(comfyPage)
|
||||
await appMode.enterBuilder()
|
||||
|
||||
// Without selecting any outputs, click the save button
|
||||
// It should trigger the connect-output popover
|
||||
await appMode.clickSave()
|
||||
|
||||
// The popover should show a message about connecting outputs
|
||||
await expect(
|
||||
page.getByText('Connect an output', { exact: false })
|
||||
).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('View type can be toggled in save-as dialog', async ({ comfyPage }) => {
|
||||
const { page, appMode } = comfyPage
|
||||
await setupSubgraphBuilder(comfyPage)
|
||||
await appMode.goToPreview()
|
||||
await appMode.clickSave()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// App should be selected by default
|
||||
const appRadio = dialog.getByRole('radio', { name: /App/ })
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
// Click Node graph option
|
||||
const graphRadio = dialog.getByRole('radio', { name: /Node graph/ })
|
||||
await graphRadio.click()
|
||||
await expect(graphRadio).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(appRadio).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
66
browser_tests/tests/changeTrackerLoadGuard.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { WorkspaceStore } from '../types/globals'
|
||||
|
||||
test.describe(
|
||||
'Change Tracker - isLoadingGraph guard',
|
||||
{ tag: '@workflow' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
afterConfigureGraph() {
|
||||
const workflow = (window.app!.extensionManager as WorkspaceStore)
|
||||
.workflow.activeWorkflow
|
||||
if (!workflow) throw new Error('No workflow found')
|
||||
// Bypass the guard to reproduce the corruption bug:
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Create tab 1: blank workflow (0 nodes)
|
||||
await comfyPage.menu.topbar.triggerTopbarCommand(['New'])
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Switch back to tab 0 (workflow-a).
|
||||
const tab0 = comfyPage.menu.topbar.getWorkflowTab('workflow-a')
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
|
||||
// switch to blank tab and back to verify no corruption
|
||||
const tab1 = comfyPage.menu.topbar.getWorkflowTab('Unsaved Workflow')
|
||||
await tab1.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// switch again and verify no corruption
|
||||
await tab0.click()
|
||||
await comfyPage.nextFrame()
|
||||
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(7)
|
||||
})
|
||||
}
|
||||
)
|
||||
276
browser_tests/tests/containAudit.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { PerfMeasurement } from '../fixtures/helpers/PerformanceHelper'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
interface ContainCandidate {
|
||||
selector: string
|
||||
testId: string | null
|
||||
tagName: string
|
||||
className: string
|
||||
subtreeSize: number
|
||||
hasFixedWidth: boolean
|
||||
isFlexChild: boolean
|
||||
hasExplicitDimensions: boolean
|
||||
alreadyContained: boolean
|
||||
score: number
|
||||
}
|
||||
|
||||
interface AuditResult {
|
||||
candidate: ContainCandidate
|
||||
baseline: Pick<PerfMeasurement, 'styleRecalcs' | 'layouts' | 'taskDurationMs'>
|
||||
withContain: Pick<
|
||||
PerfMeasurement,
|
||||
'styleRecalcs' | 'layouts' | 'taskDurationMs'
|
||||
>
|
||||
deltaRecalcsPct: number
|
||||
deltaLayoutsPct: number
|
||||
visuallyBroken: boolean
|
||||
}
|
||||
|
||||
function formatPctDelta(value: number): string {
|
||||
const sign = value >= 0 ? '+' : ''
|
||||
return `${sign}${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function pctChange(baseline: number, measured: number): number {
|
||||
if (baseline === 0) return 0
|
||||
return ((measured - baseline) / baseline) * 100
|
||||
}
|
||||
|
||||
const STABILIZATION_FRAMES = 60
|
||||
const SETTLE_FRAMES = 10
|
||||
|
||||
test.describe('CSS Containment Audit', { tag: ['@audit'] }, () => {
|
||||
test('scan large graph for containment candidates', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Walk the DOM and find candidates
|
||||
const candidates = await comfyPage.page.evaluate((): ContainCandidate[] => {
|
||||
const results: ContainCandidate[] = []
|
||||
|
||||
const graphContainer =
|
||||
document.querySelector('.graph-canvas-container') ??
|
||||
document.querySelector('[class*="comfy-vue-node"]')?.parentElement ??
|
||||
document.querySelector('.lg-node')?.parentElement
|
||||
|
||||
const root = graphContainer ?? document.body
|
||||
const allElements = root.querySelectorAll('*')
|
||||
|
||||
allElements.forEach((el) => {
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
|
||||
const subtreeSize = el.querySelectorAll('*').length
|
||||
if (subtreeSize < 5) return
|
||||
|
||||
const computed = getComputedStyle(el)
|
||||
|
||||
const containValue = computed.contain || 'none'
|
||||
const alreadyContained =
|
||||
containValue.includes('layout') || containValue.includes('strict')
|
||||
|
||||
const hasFixedWidth =
|
||||
computed.width !== 'auto' &&
|
||||
!computed.width.includes('%') &&
|
||||
computed.width !== '0px'
|
||||
|
||||
const isFlexChild =
|
||||
el.parentElement !== null &&
|
||||
getComputedStyle(el.parentElement).display.includes('flex') &&
|
||||
(computed.flexGrow !== '0' || computed.flexShrink !== '1')
|
||||
|
||||
const hasExplicitDimensions =
|
||||
hasFixedWidth ||
|
||||
(computed.minWidth !== '0px' && computed.minWidth !== 'auto') ||
|
||||
(computed.maxWidth !== 'none' && computed.maxWidth !== '0px')
|
||||
|
||||
let score = subtreeSize
|
||||
if (hasExplicitDimensions) score *= 2
|
||||
if (isFlexChild) score *= 1.5
|
||||
if (alreadyContained) score = 0
|
||||
|
||||
let selector = el.tagName.toLowerCase()
|
||||
const testId = el.getAttribute('data-testid')
|
||||
if (testId) {
|
||||
selector = `[data-testid="${testId}"]`
|
||||
} else if (el.id) {
|
||||
selector = `#${el.id}`
|
||||
} else if (el.parentElement) {
|
||||
// Use nth-child to disambiguate instead of fragile first-class fallback
|
||||
// (e.g. Tailwind utilities like .flex, .relative are shared across many elements)
|
||||
const children = Array.from(el.parentElement.children)
|
||||
const index = children.indexOf(el) + 1
|
||||
const parentTestId = el.parentElement.getAttribute('data-testid')
|
||||
if (parentTestId) {
|
||||
selector = `[data-testid="${parentTestId}"] > :nth-child(${index})`
|
||||
} else if (el.parentElement.id) {
|
||||
selector = `#${el.parentElement.id} > :nth-child(${index})`
|
||||
} else {
|
||||
const tag = el.tagName.toLowerCase()
|
||||
selector = `${tag}:nth-child(${index})`
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
selector,
|
||||
testId,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
className:
|
||||
typeof el.className === 'string' ? el.className.slice(0, 80) : '',
|
||||
subtreeSize,
|
||||
hasFixedWidth,
|
||||
isFlexChild,
|
||||
hasExplicitDimensions,
|
||||
alreadyContained,
|
||||
score
|
||||
})
|
||||
})
|
||||
|
||||
results.sort((a, b) => b.score - a.score)
|
||||
return results.slice(0, 20)
|
||||
})
|
||||
|
||||
console.log(`\nFound ${candidates.length} containment candidates\n`)
|
||||
|
||||
// Deduplicate candidates by selector (keep highest score)
|
||||
const seen = new Set<string>()
|
||||
const uniqueCandidates = candidates.filter((c) => {
|
||||
if (seen.has(c.selector)) return false
|
||||
seen.add(c.selector)
|
||||
return true
|
||||
})
|
||||
|
||||
// Measure baseline performance (idle)
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const baseline = await comfyPage.perf.stopMeasuring('baseline-idle')
|
||||
|
||||
// Take a baseline screenshot for visual comparison
|
||||
const baselineScreenshot = await comfyPage.page.screenshot()
|
||||
|
||||
// For each candidate, apply contain and measure
|
||||
const results: AuditResult[] = []
|
||||
|
||||
const testCandidates = uniqueCandidates
|
||||
.filter((c) => !c.alreadyContained && c.score > 0)
|
||||
.slice(0, 10)
|
||||
|
||||
for (const candidate of testCandidates) {
|
||||
const applied = await comfyPage.page.evaluate((sel: string) => {
|
||||
const elements = document.querySelectorAll(sel)
|
||||
let count = 0
|
||||
elements.forEach((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.style.contain = 'layout style'
|
||||
count++
|
||||
}
|
||||
})
|
||||
return count
|
||||
}, candidate.selector)
|
||||
|
||||
if (applied === 0) continue
|
||||
|
||||
for (let i = 0; i < SETTLE_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Measure with containment
|
||||
await comfyPage.perf.startMeasuring()
|
||||
for (let i = 0; i < STABILIZATION_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
const withContain = await comfyPage.perf.stopMeasuring(
|
||||
`contain-${candidate.selector}`
|
||||
)
|
||||
|
||||
// Take screenshot with containment applied to detect visual breakage.
|
||||
// Note: PNG byte comparison can produce false positives from subpixel
|
||||
// rendering and anti-aliasing. Treat "DIFF" as "needs manual review".
|
||||
const containScreenshot = await comfyPage.page.screenshot()
|
||||
const visuallyBroken = !baselineScreenshot.equals(containScreenshot)
|
||||
|
||||
// Remove containment
|
||||
await comfyPage.page.evaluate((sel: string) => {
|
||||
document.querySelectorAll(sel).forEach((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
el.style.contain = ''
|
||||
}
|
||||
})
|
||||
}, candidate.selector)
|
||||
|
||||
for (let i = 0; i < SETTLE_FRAMES; i++) {
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
results.push({
|
||||
candidate,
|
||||
baseline: {
|
||||
styleRecalcs: baseline.styleRecalcs,
|
||||
layouts: baseline.layouts,
|
||||
taskDurationMs: baseline.taskDurationMs
|
||||
},
|
||||
withContain: {
|
||||
styleRecalcs: withContain.styleRecalcs,
|
||||
layouts: withContain.layouts,
|
||||
taskDurationMs: withContain.taskDurationMs
|
||||
},
|
||||
deltaRecalcsPct: pctChange(
|
||||
baseline.styleRecalcs,
|
||||
withContain.styleRecalcs
|
||||
),
|
||||
deltaLayoutsPct: pctChange(baseline.layouts, withContain.layouts),
|
||||
visuallyBroken
|
||||
})
|
||||
}
|
||||
|
||||
// Print the report
|
||||
const divider = '='.repeat(100)
|
||||
const thinDivider = '-'.repeat(100)
|
||||
console.log('\n')
|
||||
console.log('CSS Containment Audit Results')
|
||||
console.log(divider)
|
||||
console.log(
|
||||
'Rank | Selector | Subtree | Score | DRecalcs | DLayouts | Visual'
|
||||
)
|
||||
console.log(thinDivider)
|
||||
|
||||
results
|
||||
.sort((a, b) => a.deltaRecalcsPct - b.deltaRecalcsPct)
|
||||
.forEach((r, i) => {
|
||||
const sel = r.candidate.selector.padEnd(42)
|
||||
const sub = String(r.candidate.subtreeSize).padStart(7)
|
||||
const score = String(Math.round(r.candidate.score)).padStart(5)
|
||||
const dr = formatPctDelta(r.deltaRecalcsPct)
|
||||
const dl = formatPctDelta(r.deltaLayoutsPct)
|
||||
const vis = r.visuallyBroken ? 'DIFF' : 'OK'
|
||||
console.log(
|
||||
` ${String(i + 1).padStart(2)} | ${sel} | ${sub} | ${score} | ${dr.padStart(10)} | ${dl.padStart(10)} | ${vis}`
|
||||
)
|
||||
})
|
||||
|
||||
console.log(divider)
|
||||
console.log(
|
||||
`\nBaseline: ${baseline.styleRecalcs} style recalcs, ${baseline.layouts} layouts, ${baseline.taskDurationMs.toFixed(1)}ms task duration\n`
|
||||
)
|
||||
|
||||
const alreadyContained = uniqueCandidates.filter((c) => c.alreadyContained)
|
||||
if (alreadyContained.length > 0) {
|
||||
console.log('Already contained elements:')
|
||||
alreadyContained.forEach((c) => {
|
||||
console.log(` ${c.selector} (subtree: ${c.subtreeSize})`)
|
||||
})
|
||||
}
|
||||
|
||||
expect(results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// Pan interaction perf measurement removed — covered by PR #10001 (performance.spec.ts).
|
||||
// The containment fix itself is tracked in PR #9946.
|
||||
})
|
||||
@@ -28,8 +28,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
})
|
||||
|
||||
test('Should show error overlay when loading a workflow with missing nodes in subgraphs', async ({
|
||||
@@ -42,8 +45,11 @@ test.describe('Missing nodes in Error Overlay', { tag: '@ui' }, () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingNodesTitle = errorOverlay.getByText(/Missing Node Packs/)
|
||||
await expect(missingNodesTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/missing.*installed/i)
|
||||
|
||||
// Click "See Errors" to open the errors tab and verify subgraph node content
|
||||
await errorOverlay
|
||||
@@ -102,7 +108,7 @@ test('Does not resurface missing nodes on undo/redo', async ({ comfyPage }) => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Make a change to the graph by moving a node
|
||||
@@ -210,8 +216,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should show missing models from node properties', async ({
|
||||
@@ -226,8 +235,11 @@ test.describe('Missing models in Error Tab', () => {
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
const missingModelsTitle = errorOverlay.getByText(/Missing Models/)
|
||||
await expect(missingModelsTitle).toBeVisible()
|
||||
const messages = errorOverlay.getByTestId(
|
||||
TestIds.dialogs.errorOverlayMessages
|
||||
)
|
||||
await expect(messages).toBeVisible()
|
||||
await expect(messages).toHaveText(/required model.*missing/i)
|
||||
})
|
||||
|
||||
test('Should not show missing models when widget values have changed', async ({
|
||||
@@ -240,7 +252,9 @@ test.describe('Missing models in Error Tab', () => {
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).not.toBeVisible()
|
||||
await expect(comfyPage.page.getByText(/Missing Models/)).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlayMessages)
|
||||
).not.toBeVisible()
|
||||
})
|
||||
|
||||
// Flaky test after parallelization
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -29,14 +30,14 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Error overlay shows error message', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toHaveText(/\S/)
|
||||
})
|
||||
@@ -44,10 +45,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('"See Errors" opens right side panel', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
@@ -55,10 +56,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('"See Errors" dismisses the overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /See Errors/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
})
|
||||
@@ -68,10 +69,10 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
}) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /Dismiss/i }).click()
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
@@ -82,7 +83,7 @@ test.describe('Error overlay See Errors flow', { tag: '@ui' }, () => {
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
await triggerExecutionError(comfyPage)
|
||||
|
||||
const overlay = comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
const overlay = comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
|
||||
await expect(overlay).toBeVisible()
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -24,16 +25,14 @@ test.describe('Execution', { tag: ['@smoke', '@workflow'] }, () => {
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await expect(
|
||||
comfyPage.page.locator('[data-testid="error-overlay"]')
|
||||
).toBeVisible()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.getByRole('button', { name: 'Dismiss' })
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await comfyPage.page
|
||||
.locator('[data-testid="error-overlay"]')
|
||||
.waitFor({ state: 'hidden' })
|
||||
await errorOverlay.waitFor({ state: 'hidden' })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'execution-error-unconnected-slot.png'
|
||||
)
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
92
browser_tests/tests/maskEditor.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Mask Editor', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
})
|
||||
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
await expect(dialog.locator('.maskEditor-ui-container')).toBeVisible()
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
.locator('.lg-node-header')
|
||||
await nodeHeader.click()
|
||||
await nodeHeader.click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
await contextMenu.getByText('Open in Mask Editor').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
92
browser_tests/tests/painter.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Painter', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/painter_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Renders canvas and controls',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
await expect(painterWidget).toBeVisible()
|
||||
|
||||
await expect(painterWidget.locator('canvas')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Brush')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Eraser')).toBeVisible()
|
||||
await expect(painterWidget.getByText('Clear')).toBeVisible()
|
||||
await expect(
|
||||
painterWidget.locator('input[type="color"]').first()
|
||||
).toBeVisible()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-default-state.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Drawing a stroke changes the canvas',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const canvas = node.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
const isEmptyBefore = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return true
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
return data.data.every((v, i) => (i % 4 === 3 ? v === 0 : true))
|
||||
})
|
||||
expect(isEmptyBefore).toBe(true)
|
||||
|
||||
const box = await canvas.boundingBox()
|
||||
if (!box) throw new Error('Canvas bounding box not found')
|
||||
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.3,
|
||||
box.y + box.height * 0.5
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(
|
||||
box.x + box.width * 0.7,
|
||||
box.y + box.height * 0.5,
|
||||
{ steps: 10 }
|
||||
)
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(async () => {
|
||||
const hasContent = await canvas.evaluate((el) => {
|
||||
const ctx = (el as HTMLCanvasElement).getContext('2d')
|
||||
if (!ctx) return false
|
||||
const data = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
(el as HTMLCanvasElement).width,
|
||||
(el as HTMLCanvasElement).height
|
||||
)
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasContent).toBe(true)
|
||||
}).toPass()
|
||||
|
||||
await expect(node).toHaveScreenshot('painter-after-stroke.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 23 KiB |
417
browser_tests/tests/sidebar/assets.spec.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import type { GeneratedJobSeed } from '../../fixtures/helpers/AssetsHelper'
|
||||
|
||||
async function openAssetsSidebar(
|
||||
comfyPage: ComfyPage,
|
||||
seed: Parameters<ComfyPage['assets']['seedAssets']>[0]
|
||||
) {
|
||||
await comfyPage.page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write'], {
|
||||
origin: comfyPage.url
|
||||
})
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.assets.seedAssets(seed)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
return tab
|
||||
}
|
||||
|
||||
function makeGeneratedAssets(comfyPage: ComfyPage) {
|
||||
const stacked: GeneratedJobSeed = {
|
||||
jobId: 'job-gallery-stack',
|
||||
outputs: [
|
||||
{
|
||||
filename: 'gallery-main.webp',
|
||||
displayName: 'Gallery Main',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'gallery-alt.webp',
|
||||
displayName: 'Gallery Alt',
|
||||
mediaType: 'images'
|
||||
},
|
||||
{
|
||||
filename: 'gallery-detail.webp',
|
||||
displayName: 'Gallery Detail',
|
||||
mediaType: 'images'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
sunrise: comfyPage.assets.generatedImage({
|
||||
jobId: 'job-sunrise',
|
||||
filename: 'sunrise.webp',
|
||||
displayName: 'Sunrise'
|
||||
}),
|
||||
forest: comfyPage.assets.generatedImage({
|
||||
jobId: 'job-forest',
|
||||
filename: 'forest.webp',
|
||||
displayName: 'Forest'
|
||||
}),
|
||||
stacked
|
||||
}
|
||||
}
|
||||
|
||||
function makeImportedAssets(comfyPage: ComfyPage) {
|
||||
return {
|
||||
concept: comfyPage.assets.importedImage({
|
||||
name: 'concept.png'
|
||||
}),
|
||||
reference: comfyPage.assets.importedImage({
|
||||
name: 'reference.png'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function makeWorkflowGeneratedAsset(comfyPage: ComfyPage) {
|
||||
return comfyPage.assets.generatedImage({
|
||||
jobId: 'job-workflow-sunrise',
|
||||
filename: 'workflow-sunrise.webp',
|
||||
displayName: 'Workflow Sunrise',
|
||||
workflow: await comfyPage.assets.workflowContainerFromFixture()
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Assets sidebar', () => {
|
||||
test.describe.configure({ timeout: 30_000 })
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [],
|
||||
imported: []
|
||||
})
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
|
||||
await tab.showImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows generated and imported assets, and clears search when switching tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const imported = makeImportedAssets(comfyPage)
|
||||
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise, generated.forest],
|
||||
imported: [imported.concept, imported.reference]
|
||||
})
|
||||
|
||||
await expect(tab.asset('Sunrise')).toBeVisible()
|
||||
await expect(tab.asset('Forest')).toBeVisible()
|
||||
|
||||
await tab.search('Sunrise')
|
||||
|
||||
await expect(tab.searchInput).toHaveValue('Sunrise')
|
||||
await expect(tab.asset('Sunrise')).toBeVisible()
|
||||
await expect(tab.asset('Forest')).not.toBeVisible()
|
||||
|
||||
await tab.showImported()
|
||||
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
await expect(tab.asset('concept.png')).toBeVisible()
|
||||
await expect(tab.asset('reference.png')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens preview from list view and shows the media dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise]
|
||||
})
|
||||
|
||||
await tab.switchToListView()
|
||||
await tab.openAssetPreview('Sunrise')
|
||||
|
||||
await expect(tab.previewDialog).toBeVisible()
|
||||
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
|
||||
|
||||
await tab.previewDialog.getByLabel('Close').click()
|
||||
await expect(tab.previewDialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('expands stacked outputs in list view', async ({ comfyPage }) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.stacked]
|
||||
})
|
||||
|
||||
await tab.switchToListView()
|
||||
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
|
||||
|
||||
await tab.toggleStack('Gallery Main')
|
||||
|
||||
await expect(tab.asset('Gallery Alt')).toBeVisible()
|
||||
await expect(tab.asset('Gallery Detail')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens folder view for multi-output assets, copies the job ID, and returns back', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.stacked]
|
||||
})
|
||||
|
||||
await tab.openOutputFolder('Gallery Main')
|
||||
|
||||
await expect(tab.backButton).toBeVisible()
|
||||
await expect(tab.copyJobIdButton).toBeVisible()
|
||||
await expect(tab.asset('Gallery Main')).toBeVisible()
|
||||
await expect(tab.asset('Gallery Alt')).toBeVisible()
|
||||
await expect(tab.asset('Gallery Detail')).toBeVisible()
|
||||
|
||||
await tab.copyJobIdButton.click()
|
||||
|
||||
await expect(comfyPage.visibleToasts).toContainText('Copied')
|
||||
await expect(comfyPage.visibleToasts).toContainText(
|
||||
'Job ID copied to clipboard'
|
||||
)
|
||||
await tab.searchInput.click()
|
||||
await comfyPage.clipboard.paste(tab.searchInput)
|
||||
|
||||
await expect(tab.searchInput).toHaveValue(generated.stacked.jobId)
|
||||
|
||||
await tab.backButton.click()
|
||||
await expect(tab.asset('Gallery Main')).toBeVisible()
|
||||
await expect(tab.asset('Gallery Alt')).not.toBeVisible()
|
||||
await expect(tab.asset('Gallery Detail')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('shows output asset context-menu actions and can delete an asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise, generated.forest]
|
||||
})
|
||||
|
||||
await tab.openContextMenuForAsset('Sunrise')
|
||||
|
||||
await expect(tab.contextMenuAction('Inspect asset')).toBeVisible()
|
||||
await expect(
|
||||
tab.contextMenuAction('Insert as node in workflow')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Download')).toBeVisible()
|
||||
await expect(
|
||||
tab.contextMenuAction('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Copy job ID')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Delete')).toBeVisible()
|
||||
|
||||
await tab.contextMenuAction('Delete').click()
|
||||
await comfyPage.confirmDialog.click('delete')
|
||||
|
||||
await expect(tab.asset('Sunrise')).not.toBeVisible()
|
||||
await expect(tab.asset('Forest')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens preview from the output asset context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise]
|
||||
})
|
||||
|
||||
await tab.runContextMenuAction('Sunrise', 'Inspect asset')
|
||||
|
||||
await expect(tab.previewDialog).toBeVisible()
|
||||
await expect(tab.previewImage('sunrise.webp')).toBeVisible()
|
||||
})
|
||||
|
||||
test('downloads an output asset from the context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise]
|
||||
})
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
|
||||
await tab.runContextMenuAction('Sunrise', 'Download')
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toContain('Sunrise')
|
||||
})
|
||||
|
||||
test('copies an output asset job ID from the context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise]
|
||||
})
|
||||
|
||||
await tab.runContextMenuAction('Sunrise', 'Copy job ID')
|
||||
|
||||
await expect(comfyPage.visibleToasts).toContainText('Copied to clipboard')
|
||||
|
||||
await tab.searchInput.click()
|
||||
await comfyPage.clipboard.paste(tab.searchInput)
|
||||
|
||||
await expect(tab.searchInput).toHaveValue(generated.sunrise.jobId)
|
||||
})
|
||||
|
||||
test('inserts an output asset into the workflow from the context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise]
|
||||
})
|
||||
|
||||
await tab.runContextMenuAction('Sunrise', 'Insert as node in workflow')
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
|
||||
})
|
||||
|
||||
test('opens a workflow from the output asset context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [workflowAsset]
|
||||
})
|
||||
|
||||
await tab.runContextMenuAction(
|
||||
'Workflow Sunrise',
|
||||
'Open as workflow in new tab'
|
||||
)
|
||||
|
||||
await expect(comfyPage.visibleToasts).toContainText(
|
||||
'Workflow opened in new tab'
|
||||
)
|
||||
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return (await workflowsTab.getOpenedWorkflowNames()).some((name) =>
|
||||
name.includes('workflow-sunrise')
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('exports a workflow from the output asset context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.PromptFilename', false)
|
||||
const workflowAsset = await makeWorkflowGeneratedAsset(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [workflowAsset]
|
||||
})
|
||||
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
|
||||
await tab.runContextMenuAction('Workflow Sunrise', 'Export workflow')
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toContain('workflow-sunrise.json')
|
||||
await expect(comfyPage.visibleToasts).toContainText(
|
||||
'Workflow exported successfully'
|
||||
)
|
||||
})
|
||||
|
||||
test('shows imported asset context-menu actions without output-only actions, and can insert the asset into the workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const imported = makeImportedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
imported: [imported.concept]
|
||||
})
|
||||
|
||||
await tab.showImported()
|
||||
await tab.openContextMenuForAsset('concept.png')
|
||||
|
||||
await expect(
|
||||
tab.contextMenuAction('Insert as node in workflow')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Download')).toBeVisible()
|
||||
await expect(
|
||||
tab.contextMenuAction('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Export workflow')).toBeVisible()
|
||||
await expect(tab.contextMenuAction('Copy job ID')).not.toBeVisible()
|
||||
await expect(tab.contextMenuAction('Delete')).not.toBeVisible()
|
||||
|
||||
await tab.contextMenuAction('Insert as node in workflow').click()
|
||||
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('Load Image')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows the selection footer, can clear the selection, and can download a selected asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise, generated.forest]
|
||||
})
|
||||
|
||||
await tab.selectAssets(['Sunrise', 'Forest'])
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toContainText('Assets Selected: 2')
|
||||
|
||||
await tab.selectionCountButton.click()
|
||||
await expect(tab.selectionCountButton).not.toBeVisible()
|
||||
|
||||
await tab.selectAssets(['Sunrise'])
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
|
||||
await tab.downloadSelectionButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toContain('Sunrise')
|
||||
await expect(tab.selectionCountButton).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('clears the current selection when switching tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const generated = makeGeneratedAssets(comfyPage)
|
||||
const imported = makeImportedAssets(comfyPage)
|
||||
|
||||
const tab = await openAssetsSidebar(comfyPage, {
|
||||
generated: [generated.sunrise],
|
||||
imported: [imported.concept]
|
||||
})
|
||||
|
||||
await tab.selectAssets(['Sunrise'])
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
|
||||
await tab.showImported()
|
||||
|
||||
await expect(tab.selectionCountButton).not.toBeVisible()
|
||||
await expect(tab.asset('concept.png')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -247,7 +247,7 @@ test.describe('Workflows sidebar', () => {
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
// Dismiss the error overlay
|
||||
await errorOverlay.getByRole('button', { name: 'Dismiss' }).click()
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
|
||||
// Load blank workflow
|
||||
|
||||
114
browser_tests/tests/subgraphViewport.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
function hasVisibleNodeInViewport() {
|
||||
const canvas = window.app!.canvas
|
||||
if (!canvas?.graph?._nodes?.length) return false
|
||||
|
||||
const ds = canvas.ds
|
||||
const cw = canvas.canvas.width / window.devicePixelRatio
|
||||
const ch = canvas.canvas.height / window.devicePixelRatio
|
||||
const visLeft = -ds.offset[0]
|
||||
const visTop = -ds.offset[1]
|
||||
const visRight = visLeft + cw / ds.scale
|
||||
const visBottom = visTop + ch / ds.scale
|
||||
|
||||
for (const node of canvas.graph._nodes) {
|
||||
const [nx, ny] = node.pos
|
||||
const [nw, nh] = node.size
|
||||
if (
|
||||
nx + nw > visLeft &&
|
||||
nx < visRight &&
|
||||
ny + nh > visTop &&
|
||||
ny < visBottom
|
||||
)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
test.describe('Subgraph viewport restoration', { tag: '@subgraph' }, () => {
|
||||
test('first visit fits viewport to subgraph nodes (LG)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
const graph = canvas.graph!
|
||||
const sgNode = graph._nodes.find((n) =>
|
||||
'isSubgraphNode' in n
|
||||
? (n as unknown as { isSubgraphNode: () => boolean }).isSubgraphNode()
|
||||
: false
|
||||
) as unknown as { subgraph?: typeof graph } | undefined
|
||||
if (!sgNode?.subgraph) throw new Error('No subgraph node')
|
||||
|
||||
canvas.setGraph(sgNode.subgraph)
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('first visit fits viewport to subgraph nodes (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(hasVisibleNodeInViewport), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('viewport is restored when returning to root (Vue)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const rootViewport = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('11')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(rootViewport.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(rootViewport.offset[0], 0),
|
||||
expect.closeTo(rootViewport.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
138
browser_tests/tests/zoomControls.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
|
||||
test.describe('Zoom Controls', { tag: '@canvas' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true)
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
await comfyPage.page.waitForFunction(() => window.app && window.app.canvas)
|
||||
})
|
||||
|
||||
test('Default zoom is 100% and node has a size', async ({ comfyPage }) => {
|
||||
const nodeSize = await comfyPage.page.evaluate(
|
||||
() => window.app!.graph.nodes[0].size
|
||||
)
|
||||
expect(nodeSize[0]).toBeGreaterThan(0)
|
||||
expect(nodeSize[1]).toBeGreaterThan(0)
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await expect(zoomButton).toContainText('100%')
|
||||
|
||||
const scale = await comfyPage.canvasOps.getScale()
|
||||
expect(scale).toBeCloseTo(1.0, 1)
|
||||
})
|
||||
|
||||
test('Zoom to fit reduces percentage', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
await zoomToFit.click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeLessThan(1.0)
|
||||
|
||||
await expect(zoomButton).not.toContainText('100%')
|
||||
})
|
||||
|
||||
test('Zoom out reduces percentage', async ({ comfyPage }) => {
|
||||
const initialScale = await comfyPage.canvasOps.getScale()
|
||||
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const newScale = await comfyPage.canvasOps.getScale()
|
||||
expect(newScale).toBeLessThan(initialScale)
|
||||
})
|
||||
|
||||
test('Zoom out clamps at 10% minimum', async ({ comfyPage }) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await zoomOut.click()
|
||||
}
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(0.1, 1)
|
||||
|
||||
await expect(zoomButton).toContainText('10%')
|
||||
})
|
||||
|
||||
test('Manual percentage entry allows zoom in and zoom out', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const input = comfyPage.page
|
||||
.getByTestId(TestIds.canvas.zoomPercentageInput)
|
||||
.locator('input')
|
||||
await input.focus()
|
||||
await comfyPage.page.keyboard.press('Control+a')
|
||||
await input.pressSequentially('100')
|
||||
await input.press('Enter')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.canvasOps.getScale(), { timeout: 2000 })
|
||||
.toBeCloseTo(1.0, 1)
|
||||
|
||||
const zoomIn = comfyPage.page.getByTestId(TestIds.canvas.zoomInAction)
|
||||
await zoomIn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomIn = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomIn).toBeGreaterThan(1.0)
|
||||
|
||||
const zoomOut = comfyPage.page.getByTestId(TestIds.canvas.zoomOutAction)
|
||||
await zoomOut.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const scaleAfterZoomOut = await comfyPage.canvasOps.getScale()
|
||||
expect(scaleAfterZoomOut).toBeLessThan(scaleAfterZoomIn)
|
||||
})
|
||||
|
||||
test('Clicking zoom button toggles zoom controls visibility', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const zoomButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.zoomControlsButton
|
||||
)
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const zoomToFit = comfyPage.page.getByTestId(TestIds.canvas.zoomToFitAction)
|
||||
await expect(zoomToFit).toBeVisible()
|
||||
|
||||
await zoomButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(zoomToFit).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -1,683 +0,0 @@
|
||||
===============================================================================
|
||||
____ _ _ ____
|
||||
/ ___|___ __| | ___| |__ __ _ ___ ___ / ___|__ ___ _____ _ __ _ __ ___
|
||||
| | / _ \ / _` |/ _ \ '_ \ / _` / __|/ _ \ | | / _` \ \ / / _ \ '__| '_ \/ __|
|
||||
| |__| (_) | (_| | __/ |_) | (_| \__ \ __/ | |__| (_| |\ V / __/ | | | | \__ \
|
||||
\____\___/ \__,_|\___|_.__/ \__,_|___/\___| \____\__,_| \_/ \___|_| |_| |_|___/
|
||||
|
||||
ComfyUI Frontend Architecture Adventure - Complete Walkthrough
|
||||
===============================================================================
|
||||
|
||||
Platform: Web Browser (any modern browser)
|
||||
Version: 1.0
|
||||
Author: An Architect Who Has Seen Things
|
||||
Last Updated: 2026-03-24
|
||||
Spoilers: YES. This guide contains ALL solutions and ALL endings.
|
||||
|
||||
===============================================================================
|
||||
TABLE OF CONTENTS
|
||||
===============================================================================
|
||||
|
||||
I. Introduction & Controls
|
||||
II. Game Mechanics
|
||||
III. Room Guide & Map
|
||||
IV. Challenge Solutions (SPOILERS)
|
||||
V. Optimal Route - "The ECS Enlightenment" Speedrun
|
||||
VI. All Four Endings
|
||||
VII. Achievements
|
||||
VIII. Artifacts Checklist
|
||||
IX. Pro Tips & Secrets
|
||||
|
||||
===============================================================================
|
||||
I. INTRODUCTION & CONTROLS
|
||||
===============================================================================
|
||||
|
||||
Codebase Caverns is an interactive choose-your-own-adventure game that
|
||||
teaches you the architecture of the ComfyUI frontend codebase. You explore
|
||||
10 rooms representing different architectural layers, face 9 real engineering
|
||||
challenges, collect artifacts, and reach one of 4 endings based on your
|
||||
decisions.
|
||||
|
||||
Every challenge in this game is based on REAL architectural problems
|
||||
documented in the ComfyUI frontend repo. The "correct" answers match the
|
||||
actual migration strategy being used in production.
|
||||
|
||||
CONTROLS:
|
||||
=========
|
||||
1, 2, 3 Navigate between rooms (press the number key)
|
||||
A, B, C Choose a challenge option (press the letter key)
|
||||
M Toggle the map overlay
|
||||
Escape Close the map / close ending preview
|
||||
|
||||
BUTTONS:
|
||||
========
|
||||
Map [M] Opens the room map overlay
|
||||
Restart Resets the current run (keeps achievements)
|
||||
Play Again After an ending, starts a new run
|
||||
|
||||
Your progress auto-saves to localStorage. Close the tab and come back
|
||||
later - you'll pick up right where you left off.
|
||||
|
||||
===============================================================================
|
||||
II. GAME MECHANICS
|
||||
===============================================================================
|
||||
|
||||
STATS
|
||||
=====
|
||||
You have four stats tracked in the HUD at the top:
|
||||
|
||||
Debt [||||||||..] 50 Technical debt. LOWER is better.
|
||||
Quality [|||.......] 30 Code quality. HIGHER is better.
|
||||
Morale [||||||....] 60 Team morale. HIGHER is better.
|
||||
ECS [.........] 0/5 Migration progress. 5 is max.
|
||||
|
||||
Each challenge choice modifies these stats. Your final stats determine
|
||||
which of the 4 endings you get.
|
||||
|
||||
CHALLENGES
|
||||
==========
|
||||
9 of the 10 rooms contain a one-time challenge - an architectural dilemma
|
||||
with 2-3 options. Each option has a rating:
|
||||
|
||||
[GOOD] Best practice. Matches the real migration strategy.
|
||||
Usually: Debt down, Quality up, +1 ECS progress.
|
||||
|
||||
[OK] Pragmatic but imperfect. Gets the job done.
|
||||
Mixed stat effects.
|
||||
|
||||
[BAD] Tempting but harmful. Short-term gain, long-term pain.
|
||||
Usually: Debt up or Morale down.
|
||||
|
||||
After choosing, you see your result, the recommended answer, and a link
|
||||
to the real architecture documentation that explains why.
|
||||
|
||||
ARTIFACTS
|
||||
=========
|
||||
Rooms contain collectible artifacts - key files and concepts from the
|
||||
codebase. These are auto-collected when you enter a room. They appear
|
||||
as icons in your Inventory sidebar.
|
||||
|
||||
ENDINGS
|
||||
=======
|
||||
After resolving all 8 challenges, you get one of 4 endings based on
|
||||
your accumulated stats. See Section VI for details.
|
||||
|
||||
ACHIEVEMENTS
|
||||
============
|
||||
Each ending you reach is permanently saved as an achievement badge.
|
||||
Achievements persist across runs - even after restarting. Click an
|
||||
unlocked badge to review that ending's screen.
|
||||
|
||||
===============================================================================
|
||||
III. ROOM GUIDE & MAP
|
||||
===============================================================================
|
||||
|
||||
+-------------------+
|
||||
| ENTRY POINT |
|
||||
| (src/main.ts) |
|
||||
+-+--------+------+-+
|
||||
| | |
|
||||
+----------+ | +-----------+
|
||||
| | |
|
||||
+---v----------+ +-----v--------+ +------v---------+
|
||||
| COMPONENT | | STORE | | SERVICE |
|
||||
| GALLERY | | VAULTS | | CORRIDORS |
|
||||
| [Challenge] | | [Challenge] | | [Challenge] |
|
||||
+--+------+----+ +--+------+----+ +--------+-------+
|
||||
| | | | |
|
||||
| | +----v---+ | +------v-------+
|
||||
| | | ECS | | | COMPOSABLES |
|
||||
| | | CHAMB. | | | WORKSHOP |
|
||||
| | | [Chal] | | | [Challenge] |
|
||||
| | +---+----+ | +--------------+
|
||||
| | | +----v------+
|
||||
| | +----v--+--+ |
|
||||
| | |SUBGRAPH| RENDERER |
|
||||
| | | DEPTHS | OVERLOOK |
|
||||
| | | [Chal] | [Chal] |
|
||||
| | +--------+----------+
|
||||
| |
|
||||
+--v------v----+
|
||||
| LITEGRAPH |
|
||||
| ENGINE |
|
||||
| [Challenge] |
|
||||
+------+-------+
|
||||
|
|
||||
+------v-------+
|
||||
| COMMAND |
|
||||
| FORGE |
|
||||
| [Challenge] |
|
||||
+--------------+
|
||||
|
||||
ROOM DETAILS:
|
||||
=============
|
||||
|
||||
1. THE ENTRY POINT [src/main.ts]
|
||||
No challenge. No artifacts. Starting room.
|
||||
Exits: Components (1), Stores (2), Services (3)
|
||||
|
||||
2. THE COMPONENT GALLERY [Presentation]
|
||||
Challenge: The Circular Dependency
|
||||
Artifacts: GraphView.vue
|
||||
Exits: Litegraph (1), Command Forge (2), Entry (3)
|
||||
|
||||
3. THE STORE VAULTS [State]
|
||||
Challenge: The Scattered Mutations
|
||||
Artifacts: widgetValueStore.ts, layoutStore.ts
|
||||
Exits: ECS (1), Renderer (2), Entry (3)
|
||||
|
||||
4. THE SERVICE CORRIDORS [Services]
|
||||
Challenge: The Migration Question
|
||||
Artifacts: litegraphService.ts, Extension Migration Guide
|
||||
Exits: Composables (1), Entry (2)
|
||||
|
||||
5. THE LITEGRAPH ENGINE ROOM [Graph Engine]
|
||||
Challenge: The God Object Dilemma
|
||||
Artifacts: LGraphCanvas.ts, LGraphNode.ts
|
||||
Exits: ECS (1), Components (2), Entry (3)
|
||||
|
||||
6. THE ECS ARCHITECT'S CHAMBER [ECS]
|
||||
Challenge: The ID Crossroads
|
||||
Artifacts: World Registry, Branded Entity IDs
|
||||
Exits: Subgraph Depths (1), Renderer (2), Entry (3)
|
||||
|
||||
7. THE SUBGRAPH DEPTHS [Graph Boundaries]
|
||||
Challenge: The Widget Promotion Decision
|
||||
Artifacts: SubgraphStructure, Typed Interface Contracts
|
||||
Exits: ECS (1), Litegraph (2), Entry (3)
|
||||
|
||||
8. THE RENDERER OVERLOOK [Renderer]
|
||||
Challenge: The Render-Time Mutation
|
||||
Artifacts: QuadTree Spatial Index, Y.js CRDT Layout
|
||||
Exits: ECS (1), Entry (2)
|
||||
|
||||
9. THE COMPOSABLES WORKSHOP [Composables]
|
||||
Challenge: The Collaboration Protocol
|
||||
Artifacts: useCoreCommands.ts
|
||||
Exits: Stores (1), Entry (2)
|
||||
|
||||
10. THE COMMAND FORGE [Commands & Intent]
|
||||
Challenge: The Mutation Gateway
|
||||
Artifacts: CommandExecutor, Command Interface
|
||||
Exits: Components (1), Stores (2), Entry (3)
|
||||
|
||||
===============================================================================
|
||||
IV. CHALLENGE SOLUTIONS (SPOILERS)
|
||||
===============================================================================
|
||||
|
||||
*** WARNING: FULL SOLUTIONS BELOW ***
|
||||
*** SCROLL PAST SECTION VI IF YOU WANT TO PLAY BLIND ***
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 1: The Circular Dependency | Room: Components |
|
||||
|------------------------------------------------------------------ |
|
||||
| Subgraph extends LGraph, but LGraph creates Subgraph instances. |
|
||||
| Circular import forces order-dependent barrel exports. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Composition over inheritance [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| A subgraph IS a graph - just a node with SubgraphStructure. |
|
||||
| Under graph unification, no class inheritance at all. |
|
||||
| |
|
||||
| B. Barrel file reordering [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Band-aid. The coupling remains and will break again. |
|
||||
| |
|
||||
| C. Factory injection [OK] |
|
||||
| Debt -5, Quality +10 |
|
||||
| Pragmatic fix but classes stay coupled at runtime. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 2: The Scattered Mutations | Room: Stores |
|
||||
|------------------------------------------------------------------ |
|
||||
| graph._version++ appears in 19 locations across 7 files. |
|
||||
| One missed site = silent data loss. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Centralize into graph.incrementVersion() [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| This is Phase 0a of the real migration plan. |
|
||||
| 19 sites -> 1 method. Auditable change tracking. |
|
||||
| |
|
||||
| B. Add a JavaScript Proxy [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Catches mutations but adds opaque runtime overhead. |
|
||||
| |
|
||||
| C. Leave it as-is [BAD] |
|
||||
| Debt +10, Morale +5 |
|
||||
| "It works, don't touch it" - until it doesn't. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 3: The Migration Question | Room: Services |
|
||||
|------------------------------------------------------------------ |
|
||||
| Legacy litegraph works. How to migrate to ECS without breaking |
|
||||
| production for thousands of users? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. 5-phase incremental plan [GOOD] <<< |
|
||||
| Quality +15, Morale +10, ECS +1 |
|
||||
| Foundation -> Types -> Bridge -> Systems -> Legacy Removal. |
|
||||
| Each phase independently shippable. This is the real plan. |
|
||||
| |
|
||||
| B. Big bang rewrite [BAD] |
|
||||
| Debt -10, Quality +5, Morale -20 |
|
||||
| Feature freeze + scope creep + burnout = disaster. |
|
||||
| |
|
||||
| C. Strangler fig pattern [OK] |
|
||||
| Quality +10, Morale +5 |
|
||||
| Solid pattern but lacks clear milestones without a plan. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 4: The God Object Dilemma | Room: Litegraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| LGraphCanvas: ~9,100 lines. LGraphNode: ~4,300 lines. |
|
||||
| God objects mixing rendering, serialization, connectivity, etc. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| A. Rewrite from scratch [BAD] |
|
||||
| Debt -20, Quality +5, Morale -25 |
|
||||
| Heroic rewrite stalls at month three. Team burns out. |
|
||||
| |
|
||||
| >>> B. Extract incrementally [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 |
|
||||
| Position -> Connectivity -> Rendering. Small testable PRs. |
|
||||
| This matches the actual migration strategy. |
|
||||
| |
|
||||
| C. Add a facade layer [OK] |
|
||||
| Debt +5, Quality +5, Morale +10 |
|
||||
| Nicer API but complexity lives behind the facade. |
|
||||
| |
|
||||
| NOTE: This is the only challenge where A is NOT the best answer! |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 5: The ID Crossroads | Room: ECS |
|
||||
|------------------------------------------------------------------ |
|
||||
| NodeId is number | string. Nothing prevents passing a LinkId |
|
||||
| where a NodeId is expected. Six entity kinds share one ID space. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Branded types with cast helpers [GOOD] <<< |
|
||||
| Debt -15, Quality +20, ECS +1 |
|
||||
| type NodeEntityId = number & { __brand: 'NodeEntityId' } |
|
||||
| Compile-time safety, zero runtime cost. Phase 1a. |
|
||||
| |
|
||||
| B. String prefixes at runtime [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| "node:42" - parsing overhead everywhere. |
|
||||
| |
|
||||
| C. Keep plain numbers [BAD] |
|
||||
| Debt +15, Quality -5 |
|
||||
| "Just be careful" - someone WILL pass the wrong ID. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 6: The Widget Promotion Decision | Room: Subgraph |
|
||||
|------------------------------------------------------------------ |
|
||||
| A user promotes a widget from inside a subgraph to the parent. |
|
||||
| Today this needs PromotionStore + ViewManager + PromotedWidgetView |
|
||||
| — a parallel state system. Two ECS candidates. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Connections-only: promotion = typed input [GOOD] <<< |
|
||||
| Debt -15, Quality +15, Morale +5, ECS +1 |
|
||||
| Promotion = adding an interface input. Type->widget mapping |
|
||||
| creates the widget automatically. Eliminates PromotionStore, |
|
||||
| ViewManager, and PromotedWidgetView entirely. |
|
||||
| |
|
||||
| B. Simplified component promotion [OK] |
|
||||
| Debt -5, Quality +10, Morale +5 |
|
||||
| WidgetPromotion component on widget entities. Removes |
|
||||
| ViewManager but keeps promotion as a distinct concept. |
|
||||
| Shared subgraph instance ambiguity remains. |
|
||||
| |
|
||||
| C. Keep the current three-layer system [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| The parallel state system persists indefinitely. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 7: The Render-Time Mutation | Room: Renderer |
|
||||
|------------------------------------------------------------------ |
|
||||
| drawNode() calls _setConcreteSlots() and arrange() during the |
|
||||
| render pass. Draw order affects layout. Classic mutation-in- |
|
||||
| render bug. |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Separate update and render phases [GOOD] <<< |
|
||||
| Debt -15, Quality +15, ECS +1 |
|
||||
| Input -> Update (layout) -> Render (read-only). |
|
||||
| Matches the ECS system pipeline design. |
|
||||
| |
|
||||
| B. Dirty flags and deferred render [OK] |
|
||||
| Debt -5, Quality +5, Morale +5 |
|
||||
| Reduces symptoms but render pass can still mutate. |
|
||||
| |
|
||||
| NOTE: Only 2 options here. Both are reasonable; A is optimal. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 8: The Collaboration Protocol | Room: Composables |
|
||||
|------------------------------------------------------------------ |
|
||||
| Multiple users want to edit the same workflow simultaneously. |
|
||||
| layoutStore already extracts position data. How to sync? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Y.js CRDTs [GOOD] <<< |
|
||||
| Debt -10, Quality +15, Morale +10 |
|
||||
| Conflict-free replicated data types. Already proven. |
|
||||
| This is what the real layoutStore uses. |
|
||||
| |
|
||||
| B. Polling-based sync [BAD] |
|
||||
| Debt +10, Quality -5, Morale -5 |
|
||||
| Flickering, lag, silent data loss. Support nightmare. |
|
||||
| |
|
||||
| C. Skip collaboration for now [OK] |
|
||||
| Morale +5 |
|
||||
| Pragmatic delay but cloud team won't be happy. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| CHALLENGE 9: The Mutation Gateway | Room: Command Forge |
|
||||
|------------------------------------------------------------------ |
|
||||
| The World's imperative API (world.setComponent()) vs. the command |
|
||||
| pattern requirement from ADR 0003. How should external callers |
|
||||
| mutate the World? |
|
||||
|------------------------------------------------------------------ |
|
||||
| |
|
||||
| >>> A. Commands as intent; systems as handlers; World as store <<< |
|
||||
| Debt -10, Quality +15, Morale +5, ECS +1 [GOOD] |
|
||||
| Caller -> Command -> System -> World -> Y.js. Commands are |
|
||||
| serializable. ADR 0003 and ADR 0008 are complementary. |
|
||||
| |
|
||||
| B. Make World.setComponent() itself serializable [OK] |
|
||||
| Debt +5, Quality +5, Morale -5 |
|
||||
| Conflates store with command layer. Batch ops become noisy. |
|
||||
| |
|
||||
| C. Skip commands - let callers mutate directly [BAD] |
|
||||
| Debt +15, Quality -10 |
|
||||
| No undo/redo, no replay, no CRDT sync, no audit trail. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
V. OPTIMAL ROUTE - "THE ECS ENLIGHTENMENT" SPEEDRUN
|
||||
===============================================================================
|
||||
|
||||
This route hits all 8 challenges picking the GOOD answer, collecting
|
||||
all 13 artifacts, visiting all 10 rooms. Order matters for efficiency
|
||||
(fewest key presses).
|
||||
|
||||
Starting stats: Debt 50, Quality 30, Morale 60, ECS 0/5
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Component Gallery
|
||||
|
||||
COMPONENT GALLERY
|
||||
Challenge: The Circular Dependency -> Press A (Composition)
|
||||
[Debt 40, Quality 45, Morale 65, ECS 1/5]
|
||||
Press 2 -> Command Forge
|
||||
|
||||
THE COMMAND FORGE
|
||||
Challenge: The Mutation Gateway -> Press A (Commands as intent)
|
||||
[Debt 30, Quality 60, Morale 70, ECS 2/5]
|
||||
Press 2 -> Store Vaults
|
||||
|
||||
STORE VAULTS
|
||||
Challenge: The Scattered Mutations -> Press A (Centralize)
|
||||
[Debt 15, Quality 75, Morale 70, ECS 3/5]
|
||||
Press 1 -> ECS Chamber
|
||||
|
||||
ECS ARCHITECT'S CHAMBER
|
||||
Challenge: The ID Crossroads -> Press A (Branded types)
|
||||
[Debt 0, Quality 95, Morale 70, ECS 4/5]
|
||||
Press 1 -> Subgraph Depths
|
||||
|
||||
SUBGRAPH DEPTHS
|
||||
Challenge: The Widget Promotion Decision -> Press A (Connections-only)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 1 -> ECS Chamber
|
||||
Press 2 -> Renderer
|
||||
|
||||
RENDERER OVERLOOK
|
||||
Challenge: The Render-Time Mutation -> Press A (Separate phases)
|
||||
[Debt 0, Quality 100, Morale 75, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 3 -> Services
|
||||
|
||||
SERVICE CORRIDORS
|
||||
Challenge: The Migration Question -> Press A (5-phase plan)
|
||||
[Debt 0, Quality 100, Morale 85, ECS 5/5]
|
||||
Press 1 -> Composables
|
||||
|
||||
COMPOSABLES WORKSHOP
|
||||
Challenge: The Collaboration Protocol -> Press A (Y.js CRDTs)
|
||||
[Debt 0, Quality 100, Morale 95, ECS 5/5]
|
||||
Press 2 -> Entry Point
|
||||
|
||||
ENTRY POINT
|
||||
Press 1 -> Components
|
||||
Press 1 -> Litegraph
|
||||
|
||||
LITEGRAPH ENGINE ROOM
|
||||
Challenge: The God Object Dilemma -> Press B (Extract incrementally)
|
||||
[Debt 0, Quality 100, Morale 100, ECS 5/5]
|
||||
|
||||
FINAL STATS: Debt 0 | Quality 100 | Morale 100 | ECS 5/5
|
||||
|
||||
*** ENDING: THE ECS ENLIGHTENMENT ***
|
||||
|
||||
Total key presses: 28 (including challenge answers)
|
||||
Rooms visited: 10/10
|
||||
Artifacts: 16/16
|
||||
Challenges: 9/9 correct
|
||||
|
||||
===============================================================================
|
||||
VI. ALL FOUR ENDINGS
|
||||
===============================================================================
|
||||
|
||||
Endings are checked in order. First match wins.
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 1: THE ECS ENLIGHTENMENT [BEST] |
|
||||
| |
|
||||
| Requirements: Debt < 25 AND Quality >= 75 AND Morale >= 60 |
|
||||
| |
|
||||
| "The World registry hums with clean data. Node removal: |
|
||||
| 30 lines instead of 107. Serialization: one system instead |
|
||||
| of six scattered methods. Branded IDs catch bugs at compile |
|
||||
| time. Y.js CRDTs enable real-time collaboration. The team |
|
||||
| ships features faster than ever." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick ALL good answers. Hard to miss if you |
|
||||
| read the hints carefully. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 2: THE CLEAN ARCHITECTURE [GOOD] |
|
||||
| |
|
||||
| Requirements: Debt < 40 AND Quality >= 50 |
|
||||
| |
|
||||
| "The migration completes on schedule. Systems hum along, |
|
||||
| the ECS World holds most entity state, and the worst god |
|
||||
| objects have been tamed." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick mostly good answers, 1-2 OK answers. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 3: THE ETERNAL REFACTOR [MEH] |
|
||||
| |
|
||||
| Requirements: Debt < 70 |
|
||||
| |
|
||||
| "The migration... continues. Every sprint has a 'cleanup' |
|
||||
| ticket that never quite closes." |
|
||||
| |
|
||||
| HOW TO GET IT: Mix of OK and BAD answers. The "safe" middle. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
.--------------------------------------------------------------------.
|
||||
| |
|
||||
| ENDING 4: THE SPAGHETTI SINGULARITY [WORST] |
|
||||
| |
|
||||
| Requirements: Debt >= 70 (catch-all) |
|
||||
| |
|
||||
| "The god objects grew sentient. LGraphCanvas hit 12,000 lines |
|
||||
| and developed a circular dependency with itself." |
|
||||
| |
|
||||
| HOW TO GET IT: Pick all BAD answers. You have to try. |
|
||||
| Starting debt is 50, so you need +20 from bad choices. |
|
||||
'--------------------------------------------------------------------'
|
||||
|
||||
===============================================================================
|
||||
VII. ACHIEVEMENTS
|
||||
===============================================================================
|
||||
|
||||
Achievements are permanently saved across runs. You need 4 playthroughs
|
||||
(minimum) to unlock all endings, since each run can only reach one.
|
||||
|
||||
[x] The ECS Enlightenment - All good answers
|
||||
[x] The Clean Architecture - Mostly good, few OK
|
||||
[x] The Eternal Refactor - Mix of OK and bad
|
||||
[x] The Spaghetti Singularity - Maximize debt (see pro tip below)
|
||||
|
||||
Click any unlocked achievement badge in the Endings sidebar panel
|
||||
to review that ending's screen without resetting your current game.
|
||||
|
||||
PRO TIP: "The Spaghetti Singularity" requires Debt >= 70. This is
|
||||
TRICKY because some "bad" answers actually LOWER debt! Rewrites
|
||||
(Litegraph A: Debt -20) and big bang rewrites (Services B: Debt -10)
|
||||
reduce debt short-term even though they tank morale.
|
||||
|
||||
To hit Debt >= 70 you must pick options that ADD debt or leave it
|
||||
alone. Here's the proven path (starting at Debt 50):
|
||||
|
||||
Components: B (Barrel file reordering) Debt +10 -> 60
|
||||
Command Forge: C (Skip commands) Debt +15 -> 75
|
||||
Stores: C (Leave it as-is) Debt +10 -> 85
|
||||
Services: C (Strangler fig) Debt +0 -> 85
|
||||
Litegraph: C (Add a facade) Debt +5 -> 90
|
||||
ECS: C (Keep plain numbers) Debt +15 -> 100
|
||||
Subgraph: C (Keep three-layer system) Debt +10 -> 100
|
||||
Renderer: B (Dirty flags) Debt -5 -> 95
|
||||
Composables: B (Polling-based sync) Debt +10 -> 100
|
||||
|
||||
Final: Debt 100 / Quality 10 / Morale 50 -> SPAGHETTI SINGULARITY
|
||||
|
||||
WARNING: Picking "all bad-rated answers" does NOT work! The bad
|
||||
answers for Litegraph (A: Rewrite, Debt -20) and Services (B: Big
|
||||
bang, Debt -10) have negative debt effects that pull you back
|
||||
under 70.
|
||||
|
||||
===============================================================================
|
||||
VIII. ARTIFACTS CHECKLIST
|
||||
===============================================================================
|
||||
|
||||
Room | Artifact | Type
|
||||
==================|============================|==================
|
||||
Component Gallery | GraphView.vue | Component
|
||||
Store Vaults | widgetValueStore.ts | Proto-ECS Store
|
||||
Store Vaults | layoutStore.ts | Proto-ECS Store
|
||||
Service Corridors | litegraphService.ts | Service
|
||||
Service Corridors | Extension Migration Guide | Design Pattern
|
||||
Litegraph Engine | LGraphCanvas.ts | God Object
|
||||
Litegraph Engine | LGraphNode.ts | God Object
|
||||
ECS Chamber | World Registry | ECS Core
|
||||
ECS Chamber | Branded Entity IDs | Type Safety
|
||||
Subgraph Depths | SubgraphStructure | ECS Component
|
||||
Subgraph Depths | Typed Interface Contracts | Design Pattern
|
||||
Renderer Overlook | QuadTree Spatial Index | Data Structure
|
||||
Renderer Overlook | Y.js CRDT Layout | Collaboration
|
||||
Composables | useCoreCommands.ts | Composable
|
||||
Command Forge | CommandExecutor | ECS Core
|
||||
Command Forge | Command Interface | Design Pattern
|
||||
|
||||
Total: 16 artifacts across 9 rooms.
|
||||
Entry Point has no artifacts.
|
||||
|
||||
===============================================================================
|
||||
IX. PRO TIPS & SECRETS
|
||||
===============================================================================
|
||||
|
||||
* Your game auto-saves after every room change and challenge. Close
|
||||
the tab and come back anytime - you won't lose progress.
|
||||
|
||||
* The Restart button in the HUD resets your run but KEEPS your
|
||||
achievement badges. Use it to go for a different ending.
|
||||
|
||||
* Every code reference in the room descriptions is a clickable link
|
||||
to the actual file on GitHub. Open them in new tabs to read the
|
||||
real code while you play.
|
||||
|
||||
* After each challenge, the "Read more" link takes you to the
|
||||
architecture documentation that explains the real engineering
|
||||
rationale behind the recommended answer.
|
||||
|
||||
* The map overlay (press M) shows challenge badges:
|
||||
[?] = challenge available but not yet attempted
|
||||
[v] = challenge completed
|
||||
|
||||
* Room navigation preloads images for adjacent rooms, so transitions
|
||||
should be instant after the first visit.
|
||||
|
||||
* The Command Forge (formerly the Side Panel) teaches the Command
|
||||
Pattern - how commands relate to systems and the World. Its challenge
|
||||
covers the architectural layering from ADR 0003 and ADR 0008.
|
||||
|
||||
* The ECS Migration Progress stat maxes at 5, matching the 5 phases
|
||||
of the real migration plan. But 9 challenges can give +1 each
|
||||
(8 of the 9 GOOD answers grant +1 ECS). The Services challenge
|
||||
("5-phase plan") gives +1 ECS but no debt reduction - it's pure
|
||||
planning, not implementation.
|
||||
|
||||
* There are between 2-3 choices per challenge, giving
|
||||
3*3*3*3*3*3*2*3*3 = 13,122 possible playthroughs. But only 4
|
||||
distinct endings. Most paths lead to "The Clean Architecture"
|
||||
or "The Eternal Refactor."
|
||||
|
||||
* If you want to learn the ComfyUI frontend architecture for real,
|
||||
the recommended reading order matches the optimal speedrun route:
|
||||
1. src/main.ts (entry point)
|
||||
2. src/views/GraphView.vue (main canvas)
|
||||
3. src/stores/ (state management)
|
||||
4. src/ecs/ (the future)
|
||||
5. docs/architecture/ecs-world-command-api.md (command layer)
|
||||
6. src/renderer/core/ (canvas pipeline)
|
||||
7. docs/architecture/ecs-migration-plan.md (the plan)
|
||||
8. src/composables/ (Vue logic hooks)
|
||||
9. src/lib/litegraph/src/ (the legacy engine)
|
||||
|
||||
* The pixel art images were generated using the Z-Image Turbo
|
||||
pipeline on the same ComfyUI that this frontend controls.
|
||||
Meta, isn't it?
|
||||
|
||||
===============================================================================
|
||||
|
||||
This document Copyright (c) 2026 A Concerned Architect
|
||||
ComfyUI is maintained by Comfy-Org: https://github.com/Comfy-Org
|
||||
|
||||
"In a world of god objects, be an entity-component-system."
|
||||
|
||||
___
|
||||
| |
|
||||
___| |___
|
||||
| |
|
||||
| COMFY UI |
|
||||
| FRONTEND |
|
||||
|___________|
|
||||
| | | | | | |
|
||||
| | | | | | |
|
||||
_| | | | | | |_
|
||||
|_______________|
|
||||
|
||||
GG. GIT GUD.
|
||||
|
||||
===============================================================================
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art badge/medal icon, 128x128, dark background, achievement unlock style",
|
||||
"usage": "Each key matches an ending ID. Shown in achievements panel when that ending has been reached.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"achievements": {
|
||||
"great": {
|
||||
"title": "The ECS Enlightenment",
|
||||
"prompt": "Pixel art achievement badge of a radiant crystal temple with clean geometric architecture, bright green and gold triumphant glow, laurel wreath border, dark background"
|
||||
},
|
||||
"good": {
|
||||
"title": "The Clean Architecture",
|
||||
"prompt": "Pixel art achievement badge of a solid fortress with neat organized blocks, blue and silver steady glow, star emblem, dark background"
|
||||
},
|
||||
"mediocre": {
|
||||
"title": "The Eternal Refactor",
|
||||
"prompt": "Pixel art achievement badge of an hourglass with sand still flowing endlessly, amber and grey weary glow, circular border, dark background"
|
||||
},
|
||||
"disaster": {
|
||||
"title": "The Spaghetti Singularity",
|
||||
"prompt": "Pixel art achievement badge of a tangled mass of spaghetti code wires collapsing into a black hole, red and purple chaotic glow, cracked border, dark background"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art icon, 128x128, dark background, game UI button icon style, clean readable silhouette",
|
||||
"usage": "Each key is {room}-{choiceKey lowercase}. Used in challenge choice buttons in adventure.html.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"choices": {
|
||||
"components-a": {
|
||||
"label": "Composition over inheritance",
|
||||
"prompt": "Pixel art icon of puzzle pieces snapping together cleanly, green glow, dark background, game UI icon"
|
||||
},
|
||||
"components-b": {
|
||||
"label": "Barrel file reordering",
|
||||
"prompt": "Pixel art icon of a stack of files being shuffled with arrows, amber warning glow, dark background, game UI icon"
|
||||
},
|
||||
"components-c": {
|
||||
"label": "Factory injection",
|
||||
"prompt": "Pixel art icon of a factory building with a syringe injecting into it, blue mechanical glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-a": {
|
||||
"label": "Centralize into graph.incrementVersion()",
|
||||
"prompt": "Pixel art icon of scattered dots converging into a single glowing funnel point, green glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-b": {
|
||||
"label": "Add a JavaScript Proxy",
|
||||
"prompt": "Pixel art icon of a shield proxy intercepting arrows mid-flight, amber translucent glow, dark background, game UI icon"
|
||||
},
|
||||
"stores-c": {
|
||||
"label": "Leave it as-is",
|
||||
"prompt": "Pixel art icon of a shrug gesture with cobwebs on old machinery, grey muted glow, dark background, game UI icon"
|
||||
},
|
||||
"services-a": {
|
||||
"label": "5-phase incremental plan",
|
||||
"prompt": "Pixel art icon of five stepping stones ascending in a staircase with checkmarks, green glow, dark background, game UI icon"
|
||||
},
|
||||
"services-b": {
|
||||
"label": "Big bang rewrite",
|
||||
"prompt": "Pixel art icon of a dynamite stick with lit fuse and explosion sparks, red danger glow, dark background, game UI icon"
|
||||
},
|
||||
"services-c": {
|
||||
"label": "Strangler fig pattern",
|
||||
"prompt": "Pixel art icon of vines growing around and enveloping an old tree trunk, green and brown organic glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-a": {
|
||||
"label": "Rewrite from scratch",
|
||||
"prompt": "Pixel art icon of a wrecking ball demolishing a building into rubble, red destructive glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-b": {
|
||||
"label": "Extract incrementally",
|
||||
"prompt": "Pixel art icon of surgical tweezers carefully extracting a glowing module from a larger block, green precise glow, dark background, game UI icon"
|
||||
},
|
||||
"litegraph-c": {
|
||||
"label": "Add a facade layer",
|
||||
"prompt": "Pixel art icon of a decorative mask covering a cracked wall, yellow cosmetic glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-a": {
|
||||
"label": "Branded types with cast helpers",
|
||||
"prompt": "Pixel art icon of ID badges with distinct colored stamps and a compiler checkmark, green type-safe glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-b": {
|
||||
"label": "String prefixes at runtime",
|
||||
"prompt": "Pixel art icon of text labels being parsed with a magnifying glass at runtime, amber slow glow, dark background, game UI icon"
|
||||
},
|
||||
"ecs-c": {
|
||||
"label": "Keep plain numbers",
|
||||
"prompt": "Pixel art icon of bare numbers floating unprotected with a question mark, red risky glow, dark background, game UI icon"
|
||||
},
|
||||
"renderer-a": {
|
||||
"label": "Separate update and render phases",
|
||||
"prompt": "Pixel art icon of two clean pipeline stages labeled U and R with an arrow between them, green orderly glow, dark background, game UI icon"
|
||||
},
|
||||
"renderer-b": {
|
||||
"label": "Dirty flags and deferred render",
|
||||
"prompt": "Pixel art icon of a flag with a smudge mark and a clock showing delay, amber patch glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-a": {
|
||||
"label": "Y.js CRDTs",
|
||||
"prompt": "Pixel art icon of two documents merging seamlessly with sync arrows and no conflicts, green collaboration glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-b": {
|
||||
"label": "Polling-based sync",
|
||||
"prompt": "Pixel art icon of a clock with circular refresh arrows and flickering signal, red laggy glow, dark background, game UI icon"
|
||||
},
|
||||
"composables-c": {
|
||||
"label": "Skip collaboration for now",
|
||||
"prompt": "Pixel art icon of a single person at a desk with a pause symbol, grey neutral glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-a": {
|
||||
"label": "Connections-only: promotion = adding a typed input",
|
||||
"prompt": "Pixel art icon of a function signature with typed input slots and a green checkmark, clean minimal glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-b": {
|
||||
"label": "Simplified component promotion",
|
||||
"prompt": "Pixel art icon of a widget being lifted up with a promotion arrow and a component badge, amber glow, dark background, game UI icon"
|
||||
},
|
||||
"subgraph-c": {
|
||||
"label": "Keep the current three-layer system",
|
||||
"prompt": "Pixel art icon of three stacked translucent layers with proxy shadows underneath, red complex glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-a": {
|
||||
"label": "Commands as intent; systems as handlers; World as store",
|
||||
"prompt": "Pixel art icon of a layered architectural diagram with arrows flowing top-to-bottom through five labeled tiers, green glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-b": {
|
||||
"label": "Make World.setComponent() itself serializable",
|
||||
"prompt": "Pixel art icon of a database with every cell being logged into a scroll, amber overflow glow, dark background, game UI icon"
|
||||
},
|
||||
"sidepanel-c": {
|
||||
"label": "Skip commands — let callers mutate directly",
|
||||
"prompt": "Pixel art icon of multiple hands reaching into a glowing orb simultaneously causing cracks, red chaos glow, dark background, game UI icon"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art icon on transparent black background, 128x128, clean edges, glowing accent color, game inventory item style",
|
||||
"usage": "Each key is an artifact ID used in adventure.html. Generate one icon per artifact.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "128x128"
|
||||
},
|
||||
"artifacts": {
|
||||
"graphview": {
|
||||
"name": "GraphView.vue",
|
||||
"type": "Component",
|
||||
"prompt": "Pixel art icon of a glowing canvas frame with connected nodes and wires inside, blue accent glow, dark background, game inventory item"
|
||||
},
|
||||
"widgetvaluestore": {
|
||||
"name": "widgetValueStore.ts",
|
||||
"type": "Proto-ECS Store",
|
||||
"prompt": "Pixel art icon of a vault door with a glowing slider widget embossed on it, purple and gold accents, dark background, game inventory item"
|
||||
},
|
||||
"layoutstore": {
|
||||
"name": "layoutStore.ts",
|
||||
"type": "Proto-ECS Store",
|
||||
"prompt": "Pixel art icon of a grid blueprint with glowing position markers, purple accent lines, dark background, game inventory item"
|
||||
},
|
||||
"litegraphservice": {
|
||||
"name": "litegraphService.ts",
|
||||
"type": "Service",
|
||||
"prompt": "Pixel art icon of a gear with a graph node symbol in the center, copper and blue metallic glow, dark background, game inventory item"
|
||||
},
|
||||
"lgraphcanvas": {
|
||||
"name": "LGraphCanvas.ts",
|
||||
"type": "God Object",
|
||||
"prompt": "Pixel art icon of a massive cracked monolith radiating red warning light, labeled 9100, ominous dark background, game inventory item"
|
||||
},
|
||||
"lgraphnode": {
|
||||
"name": "LGraphNode.ts",
|
||||
"type": "God Object",
|
||||
"prompt": "Pixel art icon of an oversized cube with tangled wires bursting from every face, red and amber glow, dark background, game inventory item"
|
||||
},
|
||||
"world-registry": {
|
||||
"name": "World Registry",
|
||||
"type": "ECS Core",
|
||||
"prompt": "Pixel art icon of a glowing crystalline orb containing tiny entity symbols, bright blue and white aura, dark background, game inventory item"
|
||||
},
|
||||
"branded-ids": {
|
||||
"name": "Branded Entity IDs",
|
||||
"type": "Type Safety",
|
||||
"prompt": "Pixel art icon of a set of ID cards with distinct colored borders and brand stamps, green checkmark glow, dark background, game inventory item"
|
||||
},
|
||||
"quadtree": {
|
||||
"name": "QuadTree Spatial Index",
|
||||
"type": "Data Structure",
|
||||
"prompt": "Pixel art icon of a square recursively divided into four quadrants with glowing dots at intersections, teal accent, dark background, game inventory item"
|
||||
},
|
||||
"yjs-crdt": {
|
||||
"name": "Y.js CRDT Layout",
|
||||
"type": "Collaboration",
|
||||
"prompt": "Pixel art icon of two overlapping document layers merging with sync arrows, purple and green glow, dark background, game inventory item"
|
||||
},
|
||||
"usecorecommands": {
|
||||
"name": "useCoreCommands.ts",
|
||||
"type": "Composable",
|
||||
"prompt": "Pixel art icon of a hook tool with keyboard key symbols orbiting it, yellow and blue glow, dark background, game inventory item"
|
||||
},
|
||||
"subgraph-structure": {
|
||||
"name": "SubgraphStructure",
|
||||
"type": "ECS Component",
|
||||
"prompt": "Pixel art icon of nested rectangular frames inside each other like Russian dolls with glowing typed connections at each boundary, purple and teal accent, dark background, game inventory item"
|
||||
},
|
||||
"typed-contracts": {
|
||||
"name": "Typed Interface Contracts",
|
||||
"type": "Architecture",
|
||||
"prompt": "Pixel art icon of a sealed scroll with a glowing typed signature stamp and interface brackets, gold and blue accent, dark background, game inventory item"
|
||||
},
|
||||
"command-executor": {
|
||||
"name": "CommandExecutor",
|
||||
"type": "ECS Core",
|
||||
"prompt": "Pixel art icon of a glowing anvil with a gear and execute arrow symbol, blue-purple forge glow, dark background, game inventory item"
|
||||
},
|
||||
"command-interface": {
|
||||
"name": "Command Interface",
|
||||
"type": "Design Pattern",
|
||||
"prompt": "Pixel art icon of a sealed scroll with a type discriminator tag and execute method seal, blue glow, dark background, game inventory item"
|
||||
},
|
||||
"extension-migration": {
|
||||
"name": "Extension Migration Guide",
|
||||
"type": "Design Pattern",
|
||||
"prompt": "Pixel art icon of a scroll with legacy code on left transforming via arrow to ECS code on right, green transition glow, dark background, game inventory item"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"meta": {
|
||||
"style": "Pixel art, 16:9 aspect ratio, dark moody palette with glowing accent lighting",
|
||||
"usage": "Each key corresponds to a room ID in adventure.html. Generate images with generate-images.py.",
|
||||
"model": "Z-Image Turbo (no LoRA)",
|
||||
"resolution": "1152x640",
|
||||
"generated": "2026-03-24"
|
||||
},
|
||||
"rooms": {
|
||||
"entry": {
|
||||
"title": "The Entry Point",
|
||||
"prompt": "Pixel art of a glowing terminal in a vast dark server room, Vue.js and TypeScript logos floating as holographic projections, three corridors branching ahead lit by blue, green, and purple lights",
|
||||
"path": "images/entry.png"
|
||||
},
|
||||
"components": {
|
||||
"title": "The Component Gallery",
|
||||
"prompt": "Pixel art gallery hall with framed Vue component cards hung on stone walls, a massive canvas painting labeled 'GraphView' in the center, smaller panels flanking either side, warm torchlight",
|
||||
"path": "images/components.png"
|
||||
},
|
||||
"stores": {
|
||||
"title": "The Store Vaults",
|
||||
"prompt": "Pixel art underground vault with 60 glowing vault doors lining the walls, three doors in front glow brightest (labeled widget, layout, promotion), a Pinia pineapple emblem etched in stone above",
|
||||
"path": "images/stores.png"
|
||||
},
|
||||
"services": {
|
||||
"title": "The Service Corridors",
|
||||
"prompt": "Pixel art clean corridors with labeled pipes connecting rooms overhead, data flowing as glowing particles through transparent tubes, service names etched on brass plaques",
|
||||
"path": "images/services.png"
|
||||
},
|
||||
"litegraph": {
|
||||
"title": "The Litegraph Engine Room",
|
||||
"prompt": "Pixel art dark engine room with three massive monolith machines labeled 9100, 4300, and 3100 lines of code, warning lights flashing amber, tangled wires and cables everywhere",
|
||||
"path": "images/litegraph.png"
|
||||
},
|
||||
"ecs": {
|
||||
"title": "The ECS Architect's Chamber",
|
||||
"prompt": "Pixel art architect's drafting room with blueprints pinned to walls showing entity-component diagrams, a glowing World orb floating in the center, branded ID cards scattered across the desk",
|
||||
"path": "images/ecs.png"
|
||||
},
|
||||
"subgraph": {
|
||||
"title": "The Subgraph Depths",
|
||||
"prompt": "Pixel art recursive fractal chamber where identical rooms nest inside each other like Russian dolls, typed contract scrolls float at each boundary doorway, a DAG tree diagram glows on the ceiling",
|
||||
"path": "images/subgraph.png"
|
||||
},
|
||||
"renderer": {
|
||||
"title": "The Renderer Overlook",
|
||||
"prompt": "Pixel art observation deck overlooking a vast canvas being painted by precise robotic arms, Y.js CRDT symbols floating in the air, a QuadTree grid visible on the floor below",
|
||||
"path": "images/renderer.png"
|
||||
},
|
||||
"composables": {
|
||||
"title": "The Composables Workshop",
|
||||
"prompt": "Pixel art workshop with hooks hanging from a pegboard wall, each labeled (useCoreCommands, useCanvasDrop, etc.), workbenches for auth, canvas, and queue domains, cozy lantern light",
|
||||
"path": "images/composables.png"
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "The Command Forge",
|
||||
"prompt": "Pixel art anvil forge where glowing command scrolls are being hammered into structured objects, a layered diagram on the wall showing five architectural tiers connected by arrows, blue and purple forge light, dark background",
|
||||
"path": "images/sidepanel.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
"""
|
||||
Generate pixel art inventory icons for the Architecture Adventure game.
|
||||
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
|
||||
Skips icons that already exist on disk.
|
||||
|
||||
Usage: python docs/architecture/generate-icons.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
COMFY_URL = "http://localhost:8188"
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ARTIFACT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-icon-prompts.json")
|
||||
CHOICE_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-choice-icon-prompts.json")
|
||||
ACHIEVEMENT_PROMPTS = os.path.join(SCRIPT_DIR, "adventure-achievement-icon-prompts.json")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "icons")
|
||||
BASE_SEED = 7777
|
||||
WIDTH = 128
|
||||
HEIGHT = 128
|
||||
|
||||
|
||||
def build_workflow(prompt_text, seed, prefix):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "lumina2",
|
||||
"device": "default",
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": "ae.safetensors"},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {"shift": 3, "model": ["1", 0]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt_text, "clip": ["2", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["6", 0]},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "res_multistep",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["8", 0],
|
||||
},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def submit_prompt(workflow):
|
||||
payload = json.dumps({"prompt": workflow}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{COMFY_URL}/prompt",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
raise RuntimeError(f"HTTP {e.code}: {body}")
|
||||
|
||||
|
||||
def poll_history(prompt_id, timeout=120):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
|
||||
data = json.loads(resp.read())
|
||||
if prompt_id in data:
|
||||
return data[prompt_id]
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def download_image(filename, subfolder, dest_path):
|
||||
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
|
||||
urllib.request.urlretrieve(url, dest_path)
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Collect all icons from both prompt files
|
||||
all_icons = {}
|
||||
|
||||
with open(ARTIFACT_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["artifacts"].items():
|
||||
all_icons[icon_id] = entry["prompt"]
|
||||
|
||||
if os.path.exists(CHOICE_PROMPTS):
|
||||
with open(CHOICE_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["choices"].items():
|
||||
all_icons[icon_id] = entry["prompt"]
|
||||
|
||||
if os.path.exists(ACHIEVEMENT_PROMPTS):
|
||||
with open(ACHIEVEMENT_PROMPTS) as f:
|
||||
data = json.load(f)
|
||||
for icon_id, entry in data["achievements"].items():
|
||||
all_icons[f"ending-{icon_id}"] = entry["prompt"]
|
||||
|
||||
# Filter out already-generated icons
|
||||
to_generate = {}
|
||||
for icon_id, prompt in all_icons.items():
|
||||
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
|
||||
if os.path.exists(dest):
|
||||
print(f" Skipping {icon_id}.png (already exists)")
|
||||
else:
|
||||
to_generate[icon_id] = prompt
|
||||
|
||||
if not to_generate:
|
||||
print("All icons already generated. Nothing to do.")
|
||||
return
|
||||
|
||||
# Submit jobs
|
||||
jobs = []
|
||||
for i, (icon_id, prompt) in enumerate(to_generate.items()):
|
||||
prefix = f"adventure-icons/{icon_id}"
|
||||
wf = build_workflow(prompt, BASE_SEED + i, prefix)
|
||||
result = submit_prompt(wf)
|
||||
prompt_id = result["prompt_id"]
|
||||
jobs.append((icon_id, prompt_id))
|
||||
print(f" Submitted: {icon_id} -> {prompt_id}")
|
||||
|
||||
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
|
||||
|
||||
# Poll for completion
|
||||
completed = set()
|
||||
while len(completed) < len(jobs):
|
||||
for icon_id, prompt_id in jobs:
|
||||
if prompt_id in completed:
|
||||
continue
|
||||
history = poll_history(prompt_id, timeout=5)
|
||||
if history:
|
||||
completed.add(prompt_id)
|
||||
outputs = history.get("outputs", {})
|
||||
for node_out in outputs.values():
|
||||
for img in node_out.get("images", []):
|
||||
src_filename = img["filename"]
|
||||
subfolder = img.get("subfolder", "")
|
||||
dest = os.path.join(OUTPUT_DIR, f"{icon_id}.png")
|
||||
download_image(src_filename, subfolder, dest)
|
||||
print(f" [{len(completed)}/{len(jobs)}] {icon_id}.png downloaded")
|
||||
if len(completed) < len(jobs):
|
||||
time.sleep(2)
|
||||
|
||||
print(f"\nDone! {len(completed)} icons saved to {OUTPUT_DIR}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Generate pixel art room images for the Architecture Adventure game.
|
||||
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
|
||||
|
||||
Usage: python docs/architecture/generate-images.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
COMFY_URL = "http://localhost:8188"
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROMPTS_FILE = os.path.join(SCRIPT_DIR, "adventure-image-prompts.json")
|
||||
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "images")
|
||||
BASE_SEED = 2024
|
||||
WIDTH = 1152
|
||||
HEIGHT = 640
|
||||
|
||||
|
||||
def build_workflow(prompt_text, seed, prefix):
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "UNETLoader",
|
||||
"inputs": {
|
||||
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
|
||||
"weight_dtype": "default",
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPLoader",
|
||||
"inputs": {
|
||||
"clip_name": "qwen_3_4b.safetensors",
|
||||
"type": "lumina2",
|
||||
"device": "default",
|
||||
},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "VAELoader",
|
||||
"inputs": {"vae_name": "ae.safetensors"},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "ModelSamplingAuraFlow",
|
||||
"inputs": {"shift": 3, "model": ["1", 0]},
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": prompt_text, "clip": ["2", 0]},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "ConditioningZeroOut",
|
||||
"inputs": {"conditioning": ["6", 0]},
|
||||
},
|
||||
"8": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": seed,
|
||||
"control_after_generate": "fixed",
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sampler_name": "res_multistep",
|
||||
"scheduler": "simple",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["8", 0],
|
||||
},
|
||||
},
|
||||
"10": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
|
||||
},
|
||||
"11": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def submit_prompt(workflow):
|
||||
payload = json.dumps({"prompt": workflow}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{COMFY_URL}/prompt",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
resp = urllib.request.urlopen(req)
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
raise RuntimeError(f"HTTP {e.code}: {body}")
|
||||
|
||||
|
||||
def poll_history(prompt_id, timeout=120):
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
|
||||
data = json.loads(resp.read())
|
||||
if prompt_id in data:
|
||||
return data[prompt_id]
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return None
|
||||
|
||||
|
||||
def download_image(filename, subfolder, dest_path):
|
||||
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
|
||||
urllib.request.urlretrieve(url, dest_path)
|
||||
|
||||
|
||||
def main():
|
||||
with open(PROMPTS_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
||||
rooms = data["rooms"]
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
# Submit all jobs
|
||||
jobs = []
|
||||
for i, (room_id, room) in enumerate(rooms.items()):
|
||||
prefix = f"adventure/{room_id}"
|
||||
wf = build_workflow(room["prompt"], BASE_SEED + i, prefix)
|
||||
result = submit_prompt(wf)
|
||||
prompt_id = result["prompt_id"]
|
||||
jobs.append((room_id, prompt_id, prefix))
|
||||
print(f" Submitted: {room_id} -> {prompt_id}")
|
||||
|
||||
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
|
||||
|
||||
# Poll for completion
|
||||
completed = set()
|
||||
while len(completed) < len(jobs):
|
||||
for room_id, prompt_id, prefix in jobs:
|
||||
if prompt_id in completed:
|
||||
continue
|
||||
history = poll_history(prompt_id, timeout=5)
|
||||
if history:
|
||||
completed.add(prompt_id)
|
||||
# Find output filename
|
||||
outputs = history.get("outputs", {})
|
||||
for node_id, node_out in outputs.items():
|
||||
images = node_out.get("images", [])
|
||||
for img in images:
|
||||
src_filename = img["filename"]
|
||||
subfolder = img.get("subfolder", "")
|
||||
dest = os.path.join(OUTPUT_DIR, f"{room_id}.png")
|
||||
download_image(src_filename, subfolder, dest)
|
||||
print(f" [{len(completed)}/{len(jobs)}] {room_id}.png downloaded")
|
||||
if len(completed) < len(jobs):
|
||||
time.sleep(2)
|
||||
|
||||
print(f"\nDone! {len(completed)} images saved to {OUTPUT_DIR}/")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 23 KiB |