Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a45d43f5f7 | |||
| 5b68c5b84e | |||
| 5c068ace81 | |||
| f4397db869 | |||
| 9e5c5abbdf | |||
| 8b949484c1 | |||
| 9887a5958a | |||
| cc6b376f56 |
@@ -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,9 +44,11 @@ jobs:
|
||||
- name: Check patch formatting
|
||||
run: |
|
||||
git diff --check HEAD^ HEAD
|
||||
bash -n scripts/integration.sh scripts/cleanup-integration.sh scripts/verify-studio-production.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
|
||||
@@ -46,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
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -48,9 +48,11 @@ export const useGuidanceAccount = () => useContext(AccountContext);
|
||||
export default function GuidanceShell({
|
||||
children,
|
||||
title = "探索文档",
|
||||
standalone = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
standalone?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
@@ -187,6 +189,19 @@ export default function GuidanceShell({
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (standalone) return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={s.accountPage}>
|
||||
<Head><title>{`${title} · 啡跃`}</title></Head>
|
||||
<a className={s.skipLink} href="#account-content">跳到正文</a>
|
||||
<header className={s.accountHeader}>
|
||||
<Link href="/" aria-label="啡跃首页"><Brand /></Link>
|
||||
<div><Link href="/">返回首页</Link><ThemeToggle /></div>
|
||||
</header>
|
||||
<main id="account-content">{children}</main>
|
||||
</div>
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
return (
|
||||
<AccountContext.Provider value={{ user, loading, error, refresh }}>
|
||||
<div className={s.site}>
|
||||
|
||||
@@ -3,5 +3,5 @@ export function ThemeToggle() {
|
||||
const [dark, setDark] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => { const sync = () => setDark(document.documentElement.dataset.mode === 'dark'); sync(); setReady(true); window.addEventListener('endoleap-theme', sync); return () => window.removeEventListener('endoleap-theme', sync); }, []);
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}>◐<span>{dark ? '浅色' : '深色'}</span></button>;
|
||||
return <button type="button" data-theme-ready={ready} className="endoleap-theme-toggle" aria-label="切换明暗主题" title={dark ? '切换浅色模式' : '切换深色模式'} onClick={() => { const mode = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; const scope = document.documentElement.dataset.theme?.startsWith('canvas-') ? 'canvas' : 'home'; document.documentElement.dataset.mode = mode; document.documentElement.dataset.theme = `${scope}-${mode}`; try { localStorage.setItem('endoleap.theme', mode); } catch {} window.dispatchEvent(new Event('endoleap-theme')); }}><span aria-hidden="true">◐</span></button>;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ function localNext(value: string | string[] | undefined) {
|
||||
}
|
||||
export default function AccountPage() {
|
||||
return (
|
||||
<GuidanceShell title="邮箱账户">
|
||||
<GuidanceShell title="登录与注册" standalone>
|
||||
<AccountContent />
|
||||
</GuidanceShell>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,11 @@ body{margin:0}
|
||||
[data-theme$="-dark"] .endoleap-mark-dark{display:block!important}
|
||||
.endoleap-wordmark{display:flex!important;flex-direction:column;font-size:17px!important;color:var(--foreground-strong)!important;letter-spacing:.02em!important;margin:0!important;gap:3px}
|
||||
.endoleap-wordmark small{display:block;font-size:9px;font-weight:500;letter-spacing:.08em;color:var(--muted-foreground)}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:1px solid var(--border);background:var(--surface);color:var(--foreground);border-radius:8px;padding:7px 9px;font-size:12px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle{display:inline-flex;align-items:center;justify-content:center;gap:5px;border:0;background:transparent;color:var(--muted-foreground);border-radius:8px;padding:8px;width:32px;height:32px;font-size:16px;line-height:1.2;flex-shrink:0;cursor:pointer}
|
||||
button.endoleap-theme-toggle:hover{background:var(--row-hover)}
|
||||
:where(a,button,input,textarea,select):focus-visible{outline:2px solid var(--ring);outline-offset:3px}
|
||||
::selection{background:var(--selection);color:var(--foreground)}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:none}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
@media(max-width:600px){.endoleap-theme-toggle span{display:inline}.endoleap-brand{gap:5px}.endoleap-brand .endoleap-mark{width:26px;height:26px}.endoleap-wordmark{font-size:14px!important}}
|
||||
|
||||
button:disabled{cursor:default}
|
||||
input,textarea,select{accent-color:var(--primary)}
|
||||
|
||||
@@ -2380,3 +2380,14 @@
|
||||
.primaryButton,.primaryButton:hover{color:var(--primary-foreground);background:var(--primary)}
|
||||
.primaryButton:hover{background:var(--primary-hover)}
|
||||
.primaryButton:disabled{background:var(--disabled-background);color:var(--disabled-foreground)}
|
||||
|
||||
/* Authentication has its own public page, without workspace navigation. */
|
||||
.accountPage { min-height: 100vh; background: var(--background); color: var(--foreground); }
|
||||
.accountHeader { max-width: 1200px; margin: 0 auto; padding: 24px 32px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.accountHeader a { color: var(--muted-foreground); text-decoration: none; }
|
||||
.accountHeader > div { display: flex; align-items: center; gap: 20px; font-size: 13px; }
|
||||
.accountPage > main { max-width: 1120px; margin: 0 auto; padding: 48px 32px 72px; }
|
||||
@media (max-width: 650px) {
|
||||
.accountHeader { padding: 20px; }
|
||||
.accountPage > main { padding: 20px 20px 48px; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -102,7 +102,11 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
try {
|
||||
const navigation = await (reload ? page.reload({waitUntil:"commit"}) : page.goto(url,{waitUntil:"commit"}));
|
||||
assert.ok(navigation?.ok(), "Navigation returns a successful document");
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
if (new URL(url).pathname === "/account") {
|
||||
await page.getByRole("heading", { name: signedIn ? "你的账户" : "欢迎回来", exact: true }).waitFor();
|
||||
} else {
|
||||
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
|
||||
}
|
||||
assert.ok(sessionReplies > previousSessionReplies, "The new document requests its account fixture and renders the resulting account state");
|
||||
} catch (error) {
|
||||
const probeStarted=Date.now();
|
||||
@@ -125,6 +129,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 })
|
||||
@@ -154,6 +159,9 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser)
|
||||
fullPage: true,
|
||||
});
|
||||
await navigate(base + "/account");
|
||||
assert.equal(await page.getByRole("complementary").count(), 0, "Account page has no workspace sidebar");
|
||||
assert.equal(await page.getByRole("link", { name: "创建文档", exact: true }).count(), 0);
|
||||
await page.getByRole("link", { name: "返回首页", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "创建账户", exact: true }).click();
|
||||
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
|
||||
await page
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user