Compare commits

...

12 Commits

Author SHA1 Message Date
codex-bot 5c068ace81 fix(ci): complete deferred script loading before brand assertions
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 55s
creative workspace review checks / verify (pull_request) Successful in 26m46s
2026-11-01 17:02:38 +08:00
codex-bot f4397db869 fix(ci): preserve dependency parent layers during review cleanup
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 1m5s
creative workspace review checks / verify (pull_request) Failing after 26m4s
2026-11-01 16:31:56 +08:00
codex-bot 9e5c5abbdf fix(ci): wait for restored document filters before asserting values
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 54s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 16:10:57 +08:00
codex-bot 8b949484c1 fix(ci): wait for canvas hydration instead of the window load event
creative workspace review checks / verify-deployed-canvas (pull_request) Successful in 58s
creative workspace review checks / verify (pull_request) Failing after 25m52s
2026-11-01 15:40:33 +08:00
codex-bot 9887a5958a fix(ci): bound navigation recovery and retire review images safely
creative workspace review checks / verify-deployed-canvas (pull_request) Failing after 59s
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 15:35:23 +08:00
codex-bot cc6b376f56 fix(ci): verify persistent canvas themes on the root element 2026-11-01 15:32:13 +08:00
Zayden-Jung cfa3d426a0 Merge pull request 'fix(ci): 按当前 Studio 入口调整发布验收范围' (#36) from codex/fix-release-acceptance into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m37s
Reviewed-on: #36
2026-10-31 20:50:02 +00:00
codex-bot f7c6681c47 fix(ci): align release acceptance with active Studio entry
creative workspace review checks / verify (pull_request) Has been cancelled
2026-11-01 04:46:20 +08:00
Zayden-Jung b76642e06d Merge pull request 'fix(ci): 修复生产浏览器验收的依赖路径' (#35) from codex/fix-acceptance-deps into main
creative workspace CI and deployment / verify-and-deploy (push) Failing after 11m5s
Reviewed-on: #35
2026-10-31 20:20:48 +00:00
codex-bot 2570ea13bd fix(ci): resolve browser acceptance dependencies in workspace
creative workspace review checks / verify (pull_request) Successful in 11m9s
2026-11-01 04:18:44 +08:00
Zayden-Jung d316b0de03 Merge pull request 'fix(ui): 完成啡跃主题与浏览器图标的生产接入' (#34) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 10m53s
Reviewed-on: #34
2026-10-31 19:38:12 +00:00
Zayden-Jung 861f1b13f6 Merge pull request 'feat(ui): 全站接入啡跃配色、Logo 与浏览器图标' (#33) from codex/brand-ui into main
Endoleap brand checks / verify (push) Successful in 6s
creative workspace CI and deployment / verify-and-deploy (push) Failing after 1m12s
Reviewed-on: #33
2026-10-31 17:48:29 +00:00
12 changed files with 280 additions and 19 deletions
+28 -1
View File
@@ -11,10 +11,29 @@ on:
- 'scripts/integration.sh'
- 'scripts/cleanup-integration.sh'
- 'scripts/test-integration-cleanup.py'
- 'scripts/cleanup-review-images.sh'
- 'scripts/test-review-image-cleanup.py'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify-deployed-canvas:
runs-on: ces-control
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Verify canvas acceptance script against the designated deployed server
run: |
node --check apps/studio/scripts/infinite-canvas-smoke.cjs
wiki_image=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1)
[[ "$wiki_image" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]]
test_image="waterfall-wiki-tests:${wiki_image#waterfall-wiki:}"
docker image inspect "$test_image" >/dev/null
docker run --rm \
--mount "type=bind,src=$PWD,dst=/delivery,readonly" \
-e NODE_PATH=/repo/apps/waterfall-wiki/node_modules \
-e CHROMIUM_PATH=/usr/bin/chromium \
--entrypoint node "$test_image" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs
verify:
runs-on: ces-control
timeout-minutes: 60
@@ -25,8 +44,11 @@ jobs:
- name: Check patch formatting
run: |
git diff --check HEAD^ HEAD
bash -n scripts/integration.sh scripts/cleanup-integration.sh
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.sh scripts/cleanup-review-images.sh
node --check scripts/verify-studio-visual.cjs
python3 scripts/test-integration-cleanup.py
python3 scripts/test-review-image-cleanup.py
node --test apps/waterfall-wiki/tests/brand-navigation.test.mjs
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
@@ -45,3 +67,8 @@ jobs:
env:
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/cleanup-integration.sh
- name: Remove this run disposable review image tags
if: always()
env:
REVIEW_SHA: ${{ gitea.sha }}
run: bash scripts/cleanup-review-images.sh
+22 -4
View File
@@ -20,7 +20,9 @@ const fs=require('node:fs/promises');
async function unobstructed(id){const box=await node(id).boundingBox();assert.ok(box);for(const card of (await snapshot()).cards.filter(n=>n.id!==id)){const other=await node(card.id).boundingBox();assert.ok(other);assert.equal(box.x<other.x+other.width&&box.x+box.width>other.x&&box.y<other.y+other.height&&box.y+box.height>other.y,false,`${id} overlaps ${card.id}`)}}
const close=(a,b)=>assert.ok(Math.abs(a-b)<2,`${a} ≈ ${b}`);
async function drag(handle,dx,dy){const b=await handle.boundingBox();assert.ok(b);await page.mouse.move(b.x+b.width/2,b.y+b.height/2);await page.mouse.down();await page.mouse.move(b.x+b.width/2+dx,b.y+b.height/2+dy,{steps:12});await page.mouse.up()}
await page.goto(base+'/infinite_canvas');
const canvasResponse=await page.goto(base+'/infinite_canvas',{waitUntil:'commit',timeout:60000});
assert.ok(canvasResponse?.ok(),'Canvas document returns HTTP success');
await page.locator('button[data-theme-ready="true"]').waitFor();
await page.getByRole('button',{name:'从示例开始 ↗',exact:true}).click();
await page.getByRole('button',{name:'适应画布',exact:true}).click();
await content('brief').click({position:{x:20,y:20}});
@@ -99,12 +101,28 @@ const fs=require('node:fs/promises');
const anchorBefore=await worldAt();await page.mouse.move(1450,150);await page.keyboard.down('Control');await page.mouse.wheel(0,-80);await page.keyboard.up('Control');
await page.waitForFunction(z=>new DOMMatrix(getComputedStyle(document.querySelector('[class*=world]')).transform).a!==z,anchorBefore.z);
const anchorAfter=await worldAt();close(anchorBefore.x,anchorAfter.x);close(anchorBefore.y,anchorAfter.y);
const saved=await snapshot();await page.reload();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
await page.getByRole('button',{name:'切换明暗主题',exact:true}).click();await page.reload();await page.waitForFunction(()=>document.querySelector('main').dataset.theme==='light');
const saved=await snapshot();await page.reload({waitUntil:'commit',timeout:60000});await page.locator('button[data-theme-ready="true"]').waitFor();await page.waitForFunction(()=>!!localStorage.getItem('studio.infinite-canvas.v1'));assert.deepEqual(await snapshot(),saved);
const themeToggle=page.getByRole('button',{name:'切换明暗主题',exact:true});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
const initialMode=await page.evaluate(()=>document.documentElement.dataset.mode);
assert.ok(['light','dark'].includes(initialMode));
for(const mode of [initialMode==='light'?'dark':'light',initialMode]){
await themeToggle.click();
const matchesTheme=mode=>{
const root=document.documentElement,canvas=document.querySelector('main');
return root.dataset.mode===mode&&root.dataset.theme===`canvas-${mode}`&&localStorage.getItem('endoleap.theme')===mode&&getComputedStyle(canvas).backgroundColor===(mode==='dark'?'rgb(20, 20, 20)':'rgb(250, 250, 250)');
};
await page.waitForFunction(matchesTheme,mode);
await page.reload({waitUntil:'commit'});
await page.waitForFunction(()=>document.querySelector('[data-theme-ready]')?.dataset.themeReady==='true');
await page.waitForFunction(matchesTheme,mode);
assert.deepEqual(await snapshot(),saved,'Theme changes must preserve canvas content');
await capture('canvas-theme-'+mode);
}
if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-desktop.png'});}
await page.setViewportSize({width:390,height:844});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));
const viewport=await page.locator('[class*=viewport]').boundingBox();assert.equal(viewport.width,390);assert.equal(viewport.height,844);
if(process.env.SCREENSHOT_DIR)await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-nodes-mobile.png'});
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload and mobile bounds');
assert.deepEqual(errors,[]);console.log('Passed: selected-node re-click editing, drag, formatting, fixed composer, upload, references, task export, deletion/undo, reload, both persistent root themes and mobile bounds');
}catch(error){if(process.env.SCREENSHOT_DIR&&page){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/canvas-failure.png'}).catch(()=>{})}throw error}finally{await browser.close()}
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,32 @@
/**
* Recover once from an idempotent document GET's transient proxy failure.
* This never retries hydration, theme assertions, or mutations, and shares one
* 60-second budget across both attempts rather than extending the deadline.
* @param {(timeout: number) => Promise<{ok(): boolean, status(): number} | null>} navigate
* @param {string} url
* @param {() => number} now
*/
export async function navigateBrandDocument(navigate, url, now = Date.now) {
const deadline = now() + 60000;
for (let attempt = 1; attempt <= 2; attempt++) {
const remaining = deadline - now();
if (remaining <= 0) throw new Error(`Document navigation budget exhausted: ${url}`);
try {
const response = await navigate(remaining);
if (response?.ok()) return response;
const status = response?.status();
if (attempt === 1 && [500, 502, 503, 504].includes(status ?? 0)) {
console.warn('Brand navigation proxy recovery', JSON.stringify({url, status, attempt}));
continue;
}
throw new Error(`Document navigation failed: ${url}, HTTP ${status ?? 'no response'}`);
} catch (error) {
if (attempt === 1 && /ECONNRESET|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|EMPTY_RESPONSE|ABORTED)/.test(String(error))) {
console.warn('Brand navigation connection recovery', JSON.stringify({url, error: String(error), attempt}));
continue;
}
throw error;
}
}
throw new Error(`Document navigation failed: ${url}`);
}
@@ -0,0 +1,50 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {navigateBrandDocument} from './brand-navigation.mjs';
const response = status => ({ok: () => status === 200, status: () => status});
test('successful documents are fetched once', async () => {
let calls = 0;
await navigateBrandDocument(async () => { calls++; return response(200); }, '/canvas');
assert.equal(calls, 1);
});
test('one proxy failure can recover within the original deadline', async () => {
const timeouts = []; let elapsed = 0;
await navigateBrandDocument(async timeout => {
timeouts.push(timeout); elapsed = 15000;
return response(timeouts.length === 1 ? 500 : 200);
}, '/canvas', () => elapsed);
assert.deepEqual(timeouts, [60000, 45000]);
});
test('connection reset and interrupted bootstrap scripts can recover once', async () => {
for(const failure of ['net::ERR_CONNECTION_RESET','Document bootstrap script request failed: net::ERR_ABORTED']){
let calls = 0;
await navigateBrandDocument(async () => {
if (++calls === 1) throw new Error(failure);
return response(200);
}, '/canvas');
assert.equal(calls, 2);
}
});
test('persistent server failure is not hidden', async () => {
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(502); }, '/canvas'), /HTTP 502/);
assert.equal(calls, 2);
});
test('missing routes, authentication failures and ordinary timeouts are not retried', async () => {
for (const status of [401, 403, 404]) {
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; return response(status); }, '/canvas'), /HTTP/);
assert.equal(calls, 1);
}
let calls = 0;
await assert.rejects(navigateBrandDocument(async () => { calls++; throw new Error('Timeout 60000ms exceeded'); }, '/canvas'), /Timeout/);
assert.equal(calls, 1);
});
test('recovery cannot extend the total navigation budget', async () => {
let calls = 0, elapsed = 0;
await assert.rejects(navigateBrandDocument(async () => {
calls++; elapsed = 60000; return response(503);
}, '/canvas', () => elapsed), /budget exhausted/);
assert.equal(calls, 1);
});
@@ -1,6 +1,7 @@
/** CI-only checks against the disposable stack, never against personal documents. */
import assert from 'node:assert/strict';
import { chromium } from 'playwright-core';
import { chromium, type Request } from 'playwright-core';
import { navigateBrandDocument } from './brand-navigation.mjs';
export async function verifyBrandUI(base: string) {
for (const prefix of process.env.VERIFY_CANVAS_UI === '1' ? ['', '/studio'] : ['']) {
const icon = await fetch(`${base}${prefix}/favicon.ico`);
@@ -18,11 +19,36 @@ export async function verifyBrandUI(base: string) {
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
page.setDefaultTimeout(60000);page.setDefaultNavigationTimeout(60000);
const failures: string[] = [], scripts: {path:string;status?:number;ms:number;error?:string}[] = [];
const scriptStarted = new WeakMap<Request,number>();
page.on('request', request => { if(request.resourceType()==='script') scriptStarted.set(request,Date.now()); });
page.on('response', response => { if(response.request().resourceType()==='script') { scripts.push({path:new URL(response.url()).pathname,status:response.status(),ms:Date.now()-(scriptStarted.get(response.request())??Date.now())}); if(scripts.length>24)scripts.shift(); } });
page.on('pageerror', error => failures.push(error.message));
page.on('requestfailed', request => { failures.push(new URL(request.url()).pathname + ': ' + request.failure()?.errorText); if(request.resourceType()==='script') scripts.push({path:new URL(request.url()).pathname,ms:Date.now()-(scriptStarted.get(request)??Date.now()),error:request.failure()?.errorText}); });
const visit = async (path: string, theme: string) => {
await page.goto(base + path, { waitUntil: 'commit' });
await page.locator('button[data-theme-ready="true"]').first().waitFor();
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
console.log('Brand UI route ready:', path, theme);
const started = Date.now();
try {
// DOMContentLoaded also waits for this document's deferred scripts, unlike commit.
await navigateBrandDocument(async timeout => {
const attemptStarted=Date.now(), bootstrapFailures:string[]=[];
const failed=(request:Request)=>{
if(request.resourceType()==='script'&&(scriptStarted.get(request)??0)>=attemptStarted)
bootstrapFailures.push(request.failure()?.errorText||'unknown script failure');
};
page.on('requestfailed',failed);
try {
const response=await page.goto(base+path,{waitUntil:'domcontentloaded',timeout});
if(response?.ok()&&bootstrapFailures.length)throw new Error('Document bootstrap script request failed: '+bootstrapFailures.join('; '));
return response;
} finally {page.off('requestfailed',failed);}
}, base + path);
await page.locator('button[data-theme-ready="true"]').first().waitFor();
await page.waitForFunction(expected => document.documentElement.dataset.theme === expected, theme);
console.log('Brand UI route ready:', path, theme, Date.now() - started);
} catch (error) {
console.error('Brand UI navigation diagnostics', JSON.stringify({path, url:page.url(), elapsedMs:Date.now()-started, failures:failures.slice(-20), scripts:scripts.slice(-24), controls:await page.locator('[data-theme-ready]').evaluateAll(elements=>elements.map(el=>({ready:el.getAttribute('data-theme-ready'),display:getComputedStyle(el).display}))), body:(await page.locator('body').innerText({timeout:3000}).catch(()=>'unavailable')).slice(0,1200)}));
throw error;
}
};
const background = (selector: string) => page.locator(selector).first().evaluate(el => getComputedStyle(el).backgroundColor);
await visit('/', 'home-light');
@@ -56,6 +56,8 @@ export async function verifyGuidancePublishing(base:string){
assert.equal(await page.getByText('没有匹配文档',{exact:false}).count(),0);
await page.reload({waitUntil:'commit'});
await page.getByRole('status').filter({hasText:'找到 2 篇文档'}).waitFor();
// The matching count can also describe the unfiltered list before router hydration.
await page.waitForFunction(title=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value===title,httpDoc.title);
assert.equal(await search.inputValue(),httpDoc.title);
await page.getByRole('tab',{name:/事实依据/}).click();
await page.waitForURL(url=>url.searchParams.get('kind')==='evidence',{waitUntil:'commit'});
@@ -63,6 +65,7 @@ export async function verifyGuidancePublishing(base:string){
await page.getByRole('button',{name:'清除所有筛选'}).click();
await page.waitForURL(url=>url.search==='',{waitUntil:'commit'});
await page.locator(link).waitFor({state:'visible'});
await page.waitForFunction(()=>document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value==='');
assert.equal(await search.inputValue(),'');
await page.getByRole('navigation',{name:'主导航',exact:true}).getByRole('link',{name:'社区文档',exact:true}).click();await page.locator(link).waitFor({state:'visible'});
await page.locator(link).click();
@@ -125,6 +125,7 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
.waitFor();
await navigate(page.url(), true);
await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor();
await page.waitForFunction(() => document.querySelector<HTMLInputElement>('input[aria-label="搜索文档"]')?.value === "不存在的文档");
assert.equal(await search.inputValue(), "不存在的文档");
await page
.getByRole("button", { name: "清除所有筛选", exact: true })
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -Eeuo pipefail
: "${REVIEW_SHA:?The exact review commit SHA is required}"
[[ "$REVIEW_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Refusing invalid review SHA' >&2; exit 2; }
# Only this workflow's disposable tags; production tags, volumes and other projects stay out of scope.
for repository in creative-studio-review creative-wiki-tests creative-wiki-review; do
tag="$repository:$REVIEW_SHA"
referenced=$(docker ps -aq | xargs -r docker inspect --format '{{.Image}}')
if ! image=$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null); then
echo "No review image: $tag"
continue
fi
if printf '%s\n' "$referenced" | grep -Fxq "$image"; then
echo "Keeping container-referenced review image: $tag"
continue
fi
# Preserve parent layers so unchanged dependency installs stay cached.
docker image rm --no-prune "$tag"
done
+9 -1
View File
@@ -17,7 +17,15 @@ compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${C
cleanup(){
code=$?
trap - EXIT INT TERM
if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true; fi
if [ "$code" -ne 0 ]; then
echo 'Runner pressure at integration failure (before disposable stack cleanup)'
uptime || true
free -m || true
df -h / || true
for resource in cpu memory io; do cat "/proc/pressure/$resource" || true; done
timeout 10s docker stats --no-stream --format '{{.Name}} cpu={{.CPUPerc}} memory={{.MemUsage}} io={{.BlockIO}} pids={{.PIDs}}' || true
compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}" || true
fi
cleanup_code=0
bash scripts/cleanup-integration.sh || cleanup_code=$?
rm -f "$TEST_ENV"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Check review cleanup scope without Docker or build output."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPT = Path(__file__).with_name('cleanup-review-images.sh')
SHA = 'a' * 40
FAKE_DOCKER = '''#!/usr/bin/env python3
import json, os, sys
args=sys.argv[1:]
with open(os.environ['DOCKER_CALLS'], 'a') as output:
output.write(json.dumps(args)+'\\n')
if args[0]=='ps':
if os.environ.get('FAIL_INVENTORY'): sys.exit(1)
if os.environ.get('REFERENCED'): print('container-id')
elif args[0]=='inspect':
print('sha256:review-image')
elif args[:2]==['image','inspect']:
if os.environ.get('MISSING_IMAGE'): sys.exit(1)
print('sha256:review-image')
'''
class ReviewCleanupTests(unittest.TestCase):
def run_cleanup(self, sha=SHA, **extra):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
docker = root / 'docker'
docker.write_text(FAKE_DOCKER)
docker.chmod(0o700)
calls = root / 'calls'
result = subprocess.run(['bash', str(SCRIPT)], capture_output=True, env={
**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'],
'DOCKER_CALLS': str(calls), 'REVIEW_SHA': sha, **extra})
return result.returncode, [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else []
def test_removes_only_exact_review_tags_without_force_and_keeps_parent_cache(self):
code, calls = self.run_cleanup()
self.assertEqual(code, 0)
self.assertEqual([args for args in calls if args[:2] == ['image', 'rm']], [
['image', 'rm', '--no-prune', name + ':' + SHA] for name in
['creative-studio-review', 'creative-wiki-tests', 'creative-wiki-review']])
self.assertFalse(any('-f' in args or 'prune' in args or 'volume' in args for args in calls))
def test_invalid_sha_never_contacts_docker(self):
for value in ['', 'main', 'a' * 39, 'a' * 41, 'A' * 40, '*' * 40]:
with self.subTest(value=value):
code, calls = self.run_cleanup(value)
self.assertNotEqual(code, 0)
self.assertEqual(calls, [])
def test_container_referenced_images_are_kept(self):
code, calls = self.run_cleanup(REFERENCED='1')
self.assertEqual(code, 0)
self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls))
def test_missing_tags_are_a_successful_noop(self):
code, calls = self.run_cleanup(MISSING_IMAGE='1')
self.assertEqual(code, 0)
self.assertFalse(any(args[:2] == ['image', 'rm'] for args in calls))
def test_inventory_failure_prevents_removal(self):
code, calls = self.run_cleanup(FAIL_INVENTORY='1')
self.assertNotEqual(code, 0)
self.assertEqual(len(calls), 1)
if __name__ == '__main__':
unittest.main()
+4 -3
View File
@@ -10,7 +10,8 @@ fi
REPO_ROOT=$(git rev-parse --show-toplevel)
RESULT_DIR="$REPO_ROOT/artifacts/studio-acceptance"
MODE=${1:-verify}
[[ "$MODE" == verify || "$MODE" == --diagnose ]] || exit 2
# Legacy template visual checks are opt-in; their API compatibility checks remain below.
[[ "$MODE" == verify || "$MODE" == --diagnose || "$MODE" == --legacy-layouts ]] || exit 2
WIKI_IMAGE=$(docker inspect --format '{{.Config.Image}}' prompt-waterfall-wiki-1)
[[ "$WIKI_IMAGE" =~ ^waterfall-wiki:[0-9a-f]{40}$ ]] || { echo 'Unexpected Wiki image'; exit 1; }
TEST_IMAGE="waterfall-wiki-tests:${WIKI_IMAGE#waterfall-wiki:}"
@@ -41,11 +42,11 @@ run_api "$RESULT_DIR" verify-studio-library.mjs
docker run --rm --volumes-from prompt-waterfall-wiki-1:ro \
--mount "type=bind,src=$REPO_ROOT,dst=/delivery,readonly" \
--mount "type=bind,src=$RESULT_DIR,dst=/tmp/studio-library" \
-e AUTH_CONFIG_FILE=/auth/config.json -e NODE_PATH=/app/node_modules \
-e AUTH_CONFIG_FILE=/auth/config.json -e NODE_PATH=/repo/apps/waterfall-wiki/node_modules \
--entrypoint node "$TEST_IMAGE" /delivery/scripts/verify-studio-visual.cjs "$MODE"
docker run --rm \
--mount "type=bind,src=$REPO_ROOT,dst=/delivery,readonly" \
--mount "type=bind,src=$RESULT_DIR,dst=/tmp/studio-library" \
-e NODE_PATH=/app/node_modules -e CHROMIUM_PATH=/usr/bin/chromium \
-e NODE_PATH=/repo/apps/waterfall-wiki/node_modules -e CHROMIUM_PATH=/usr/bin/chromium \
-e SCREENSHOT_DIR=/tmp/studio-library/visual \
--entrypoint node "$TEST_IMAGE" /delivery/apps/studio/scripts/infinite-canvas-smoke.cjs
+8 -5
View File
@@ -14,15 +14,18 @@ const assert=require('node:assert/strict');
const page=await context.newPage(),errors=[],images=[];
page.on('pageerror',error=>errors.push({url:page.url(),message:error.message}));
page.on('response',async response=>{if(response.request().resourceType()==='image'){const url=new URL(response.url());images.push({path:url.pathname+url.search,status:response.status(),type:response.headers()['content-type'],bytes:(await response.body().catch(()=>Buffer.alloc(0))).length})}});
const metrics=process.argv.includes('--after-layouts')?JSON.parse(await readFile(dir+'/metrics.json','utf8')).metrics:[];
for(let i=1;i<=27&&!process.argv.includes('--after-layouts');i++){
// The retired catalogue is retained for existing API consumers, not a release UI gate.
// Explicit legacy/diagnostic runs still exercise its full overflow assertions.
const checkLegacy=process.argv.includes('--legacy-layouts')||process.argv.includes('--diagnose');
const metrics=[];
for(let i=1;i<=27&&checkLegacy;i++){
const id='ref-'+String(i).padStart(2,'0');await page.goto(base+'/studio/layouts/'+id,{waitUntil:'networkidle'});await page.locator('article.native-layout').screenshot({path:dir+'/'+id+'.png',style:'.page-tools{visibility:hidden}'});
const m=await page.locator('article.native-layout').evaluate(el=>{
const overflow=[...el.querySelectorAll('.native-copy')].filter(e=>e.scrollHeight>e.clientHeight+2||e.scrollWidth>e.clientWidth+2);
return {id:el.dataset.layoutId,elements:el.querySelectorAll('[data-element]').length,width:el.clientWidth,height:el.clientHeight,overflow:overflow.map(e=>e.closest('[data-element]').dataset.element),overflowDetails:overflow.map(e=>{const style=getComputedStyle(e);return {id:e.closest('[data-element]').dataset.element,text:e.textContent,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,fontSize:style.fontSize,lineHeight:style.lineHeight,letterSpacing:style.letterSpacing,fontFamily:style.fontFamily,fontWeight:style.fontWeight,writingMode:style.writingMode}})};
});metrics.push(m);await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
}
if(!process.argv.includes('--after-layouts')&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot');
if(checkLegacy&&!process.argv.includes('--diagnose'))assert.deepEqual(metrics.filter(m=>m.overflow.length),[],'Default copy must fit every native slot');
await page.goto(base+'/',{waitUntil:'networkidle'});await page.getByRole('heading',{name:/想法不该停在对话里/}).waitFor();await page.screenshot({path:dir+'/landing-desktop.png',fullPage:true});
await page.goto(base+'/graphic',{waitUntil:'networkidle'});
assert.equal(new URL(page.url()).pathname,'/graphic');
@@ -52,7 +55,7 @@ const assert=require('node:assert/strict');
await page.goto(base+verify.results[0].url,{waitUntil:'networkidle'});await page.screenshot({path:dir+'/standard-mobile.png',fullPage:true});
await page.goto(base+'/categories?category='+encodeURIComponent('文档入口'),{waitUntil:'domcontentloaded',timeout:60000});await page.getByRole('navigation',{name:'系统标签分类',exact:true}).waitFor({state:'visible'});assert.equal(await page.locator('.discovery-card').count(),3);await page.waitForFunction(()=>Array.from(document.querySelectorAll('.discovery-card img')).every(img=>img.complete&&img.naturalWidth>0),null,{timeout:60000});await page.screenshot({path:dir+'/document-entries.png',fullPage:true});
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');if(!process.argv.includes('--diagnose'))assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(new URL(image.path,base).pathname==='/brand-mark.svg'){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)}
assert.equal(images.filter(r=>new URL(r.path,base).pathname.startsWith('/api/media/')).length,0,'Display must never fetch originals');if(!process.argv.includes('--diagnose'))assert.equal(errors.length,0,JSON.stringify(errors));for(const image of images){if(/^\/(?:studio\/)?brand\/(?:logo-light|logo-dark|icon-dark)\.svg$/.test(new URL(image.path,base).pathname)){assert.equal(image.status,200);assert.match(image.type,/image\/svg\+xml/);continue;}assert.equal(new URL(image.path,base).pathname,'/api/preview');assert.equal(image.status,200);assert.equal(image.type,'image/webp');assert.ok(image.bytes>0&&image.bytes<=220000)}
await writeFile(dir+'/metrics.json',JSON.stringify({metrics,images,errors},null,2));await browser.close();
console.log(JSON.stringify({diagnostic:process.argv.includes('--diagnose'),pages:27,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)}));
console.log(JSON.stringify({diagnostic:process.argv.includes('--diagnose'),legacyLayoutsChecked:checkLegacy,legacyPages:metrics.length,errors:errors.length,displayImages:images.length,originalImageRequests:0,overflow:metrics.filter(m=>m.overflow.length)}));
})().catch(e=>{console.error(e.message);process.exit(1)});