diff --git a/.gitea/workflows/creative-workspace.yml b/.gitea/workflows/creative-workspace.yml index 5529d33..945d15b 100644 --- a/.gitea/workflows/creative-workspace.yml +++ b/.gitea/workflows/creative-workspace.yml @@ -9,6 +9,8 @@ on: - 'scripts/*studio*' - 'compose.creative-review.yml' - 'scripts/integration.sh' + - 'scripts/cleanup-integration.sh' + - 'scripts/test-integration-cleanup.py' concurrency: group: creative-workspace-${{ gitea.event.pull_request.number }} cancel-in-progress: true @@ -21,7 +23,10 @@ jobs: with: fetch-depth: 0 - name: Check patch formatting - run: git diff --check HEAD^ HEAD + run: | + git diff --check HEAD^ HEAD + bash -n scripts/integration.sh scripts/cleanup-integration.sh + python3 scripts/test-integration-cleanup.py - 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 @@ -35,3 +40,8 @@ jobs: WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }} GITEA_RUN_ID: ${{ gitea.run_id }} run: bash scripts/integration.sh + - name: Clean this run disposable integration resources even after cancellation + if: always() + env: + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/cleanup-integration.sh diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml index 5172da9..dba9b29 100644 --- a/.gitea/workflows/waterfall-wiki.yml +++ b/.gitea/workflows/waterfall-wiki.yml @@ -16,6 +16,8 @@ on: - 'scripts/infra.sh' - 'scripts/auth-admin.sh' - 'scripts/integration.sh' + - 'scripts/cleanup-integration.sh' + - 'scripts/test-integration-cleanup.py' - 'pnpm-workspace.yaml' jobs: @@ -29,6 +31,8 @@ jobs: fetch-depth: 0 - name: Check shared content and documentation contracts run: | + python3 scripts/test-integration-cleanup.py + bash -n scripts/integration.sh scripts/cleanup-integration.sh node scripts/check-studio-contracts.mjs node --check scripts/export-studio-series.cjs node --check scripts/save-studio-series.mjs @@ -60,3 +64,8 @@ jobs: - name: Remove temporary acceptance output after upload if: always() && steps.acceptance_evidence.outcome == 'success' run: rm -rf artifacts/studio-acceptance + - name: Clean this run disposable integration resources even after cancellation + if: always() + env: + GITEA_RUN_ID: ${{ gitea.run_id }} + run: bash scripts/cleanup-integration.sh diff --git a/apps/studio/scripts/infinite-canvas-smoke.cjs b/apps/studio/scripts/infinite-canvas-smoke.cjs index f78e547..730095e 100644 --- a/apps/studio/scripts/infinite-canvas-smoke.cjs +++ b/apps/studio/scripts/infinite-canvas-smoke.cjs @@ -6,15 +6,18 @@ const fs=require('node:fs/promises'); const base=process.env.BASE_URL||'http://8.211.154.134:18131'; assert.equal(base,'http://8.211.154.134:18131'); const browser=await chromium.launch(process.env.CHROMIUM_PATH?{executablePath:process.env.CHROMIUM_PATH,headless:true,args:['--no-sandbox','--disable-dev-shm-usage']}:{channel:'chrome',headless:true}); + let page; try{ const context=await browser.newContext({viewport:{width:1600,height:1100},storageState:process.env.STORAGE_STATE||undefined}); - const page=await context.newPage(),errors=[];let choosers=0; + page=await context.newPage();const errors=[];let choosers=0; page.on('pageerror',e=>errors.push(e.message));page.on('filechooser',()=>choosers++); const snapshot=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1'))); const node=id=>page.locator(`[data-node-id="${id}"]`); const content=id=>node(id).locator('[data-node-content]'); const waitField=(id,key,value)=>page.waitForFunction(({id,key,value})=>JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id===id)?.[key]===value,{id,key,value}); const composer=page.getByRole('region',{name:'节点编辑器'}); + const capture=async name=>{if(process.env.SCREENSHOT_DIR){await fs.mkdir(process.env.SCREENSHOT_DIR,{recursive:true});await page.screenshot({path:process.env.SCREENSHOT_DIR+'/'+name+'.png'})}}; + 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.xother.x&&box.yother.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'); @@ -28,6 +31,17 @@ const fs=require('node:fs/promises'); await waitField('brief','text','选择后再次点击进入编辑'); await node('brief').getByRole('button',{name:'加粗',exact:true}).click(); assert.equal((await snapshot()).cards.find(n=>n.id==='brief').style.bold,true); + await node('brief').getByRole('button',{name:'斜体',exact:true}).click(); + await node('brief').getByLabel('文字颜色',{exact:true}).fill('#78905e'); + await node('brief').getByRole('combobox',{name:'文字样式',exact:true}).selectOption('24'); + await page.waitForFunction(()=>{const n=JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='brief');return n.style.bold&&n.style.italic&&n.style.color==='#78905e'&&n.style.size===24}); + assert.deepEqual(await node('brief').getByRole('textbox',{name:'01 / 创意简报 内容'}).evaluate(el=>{const s=getComputedStyle(el);return {size:s.fontSize,weight:s.fontWeight,style:s.fontStyle,color:s.color}}),{size:'24px',weight:'700',style:'italic',color:'rgb(120, 144, 94)'}); + const compact=(await snapshot()).cards.find(n=>n.id==='brief'); + await node('brief').getByRole('button',{name:'展开文本节点',exact:true}).click(); + const expanded=(await snapshot()).cards.find(n=>n.id==='brief');assert.ok(expanded.width>(compact.width||320)&&expanded.height>(compact.height||350)); + await capture('canvas-text-format-desktop'); + await page.getByRole('button',{name:'撤销',exact:true}).click(); + assert.equal((await snapshot()).cards.find(n=>n.id==='brief').width,compact.width);assert.equal((await snapshot()).cards.find(n=>n.id==='brief').height,compact.height); await page.keyboard.press('Escape'); const before=(await snapshot()).cards.find(n=>n.id==='brief'); const z=await page.locator('[class*=world]').evaluate(el=>new DOMMatrix(getComputedStyle(el).transform).a); @@ -38,6 +52,7 @@ const fs=require('node:fs/promises'); await content('reference').click();assert.equal(await composer.count(),0); await page.waitForTimeout(700);await content('reference').click();await composer.waitFor();assert.equal(choosers,0,'Entering image editing must not force a file picker'); const panelBefore=await composer.boundingBox(),imageBefore=await node('reference').boundingBox(); + await capture('canvas-composer-desktop'); await page.getByRole('button',{name:'缩小',exact:true}).click(); const panelAfter=await composer.boundingBox(),imageAfter=await node('reference').boundingBox(); close(panelBefore.width,panelAfter.width);close(panelBefore.height,panelAfter.height);assert.ok(imageAfter.width!!JSON.parse(localStorage.getItem('studio.infinite-canvas.v1')).cards.find(n=>n.id==='reference').image); await node('reference').getByRole('button',{name:'图片高清 ↗',exact:true}).click(); const upscale=(await snapshot()).cards.find(n=>n.operation==='upscale');assert.ok(upscale); + await unobstructed(upscale.id); const download=page.waitForEvent('download');await composer.getByRole('button',{name:'导出任务',exact:true}).click(); const task=JSON.parse(await fs.readFile(await (await download).path(),'utf8')); assert.equal(task.target.id,upscale.id);assert.equal(task.references[0].id,'reference');assert.ok(task.references[0].image); @@ -55,6 +71,7 @@ const fs=require('node:fs/promises'); await node('brief').getByRole('button',{name:'引用 01 / 创意简报 生成',exact:true}).click(); await page.getByRole('dialog',{name:'画布操作'}).getByRole('button',{name:'脚本',exact:true}).click(); const script=(await snapshot()).cards.find(n=>n.kind==='script');assert.ok(script); + await unobstructed(script.id); await composer.getByRole('textbox',{name:'创作要求',exact:true}).fill('基于简报写六个分镜'); await waitField(script.id,'instruction','基于简报写六个分镜');assert.equal((await snapshot()).cards.find(n=>n.id===script.id).text,''); await composer.getByRole('button',{name:'画布参考',exact:true}).click(); @@ -89,5 +106,5 @@ const fs=require('node:fs/promises'); 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'); - }finally{await browser.close()} + }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}); diff --git a/apps/studio/src/lib/canvas-document.ts b/apps/studio/src/lib/canvas-document.ts index f7f55da..d170267 100644 --- a/apps/studio/src/lib/canvas-document.ts +++ b/apps/studio/src/lib/canvas-document.ts @@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { } export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); - const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id); - return parseCanvasDocument(result); + const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0]; + for(let attempt=0;attempt<=doc.cards.length+1;attempt++){ + const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y); + if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id)); + const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64)); + target.y=nextY>100000?-100000:nextY; + } + throw Error('请移动现有节点后再创建引用节点'); } export function agentTask(doc:CanvasDocument,id:string){ const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); diff --git a/apps/studio/tests/canvas-document.test.mjs b/apps/studio/tests/canvas-document.test.mjs index 26db818..cd06691 100644 --- a/apps/studio/tests/canvas-document.test.mjs +++ b/apps/studio/tests/canvas-document.test.mjs @@ -36,6 +36,30 @@ test('editing an image passes the current image to the external agent',()=>{ assert.equal(agentTask({version:1,cards:[image],links:[]},'image').sourceImage,image.image); }); +const overlaps=(a,b)=>a.xb.x&&a.yb.y; +test('branching the example keeps the source image accessible after image and script derivation',()=>{ + const scene={version:1,cards:[ + {...note,x:70,y:140}, + {...note,id:'reference',kind:'image',x:460,y:80}, + {...note,id:'shot',kind:'prompt',x:850,y:160}, + ],links:[['brief','reference'],['reference','shot']]}; + const image=deriveNode(scene,'reference',{...note,id:'hd',kind:'image',operation:'upscale',x:930,y:80,width:360,height:300}); + const result=deriveNode(image,'brief',{...note,id:'script',kind:'script',operation:'script',x:540,y:140,width:360,height:300}); + assert.deepEqual(result.cards.slice(0,3),scene.cards,'Existing content and positions stay unchanged'); + for(const added of result.cards.slice(3))for(const other of result.cards.filter(n=>n.id!==added.id))assert.equal(overlaps(added,other),false,`${added.id} blocks ${other.id}`); + assert.equal(referencedNodes(result,'hd')[0].id,'reference'); + assert.equal(referencedNodes(result,'script')[0].id,'brief'); + assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result); +}); +test('a free requested position is retained and large nodes near the coordinate limit can still branch',()=>{ + const free=deriveNode(doc,'brief',{...note,id:'free',x:2000,y:50}); + assert.deepEqual({x:free.cards[1].x,y:free.cards[1].y},{x:2000,y:50}); + const edge={version:1,cards:[{...note,x:90000,y:99600,width:1200,height:1200}],links:[]}; + const result=deriveNode(edge,'brief',{...note,id:'edge',x:90500,y:99700,width:360,height:300}); + assert.equal(overlaps(result.cards[0],result.cards[1]),false); + assert.deepEqual(parseCanvasDocument(JSON.stringify(result)),result); +}); + test('agent and browser parse the same canvas document contract',()=>{ assert.equal(readFileSync(new URL('../src/lib/canvas-document.ts',import.meta.url),'utf8'),readFileSync(new URL('../../waterfall-wiki/src/lib/canvas-document.ts',import.meta.url),'utf8')); }); diff --git a/apps/waterfall-wiki/src/lib/canvas-document.ts b/apps/waterfall-wiki/src/lib/canvas-document.ts index f7f55da..d170267 100644 --- a/apps/waterfall-wiki/src/lib/canvas-document.ts +++ b/apps/waterfall-wiki/src/lib/canvas-document.ts @@ -59,8 +59,14 @@ export function removeCanvasNode(doc:CanvasDocument,id:string):CanvasDocument { } export function deriveNode(doc:CanvasDocument,source:string,node:CanvasNode):CanvasDocument { if(doc.cards.length>=80||doc.cards.some(n=>n.id===node.id))throw Error('节点数量或标识不正确'); - const result=connectNodes({...doc,cards:[...doc.cards,node]},source,node.id); - return parseCanvasDocument(result); + const target=parseCanvasDocument({version:1,cards:[node],links:[]}).cards[0]; + for(let attempt=0;attempt<=doc.cards.length+1;attempt++){ + const blockers=doc.cards.filter(n=>target.xn.x&&target.yn.y); + if(!blockers.length)return parseCanvasDocument(connectNodes({...doc,cards:[...doc.cards,target]},source,target.id)); + const nextY=Math.max(...blockers.map(n=>n.y+(n.height||350)+64)); + target.y=nextY>100000?-100000:nextY; + } + throw Error('请移动现有节点后再创建引用节点'); } export function agentTask(doc:CanvasDocument,id:string){ const node=doc.cards.find(n=>n.id===id);if(!node)throw Error('目标节点不存在'); diff --git a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts index a1b8f6e..649d363 100644 --- a/apps/waterfall-wiki/tests/guidance-workspace-ui.ts +++ b/apps/waterfall-wiki/tests/guidance-workspace-ui.ts @@ -27,9 +27,11 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) requestedAuthor: string | null = null, published: Guidance | null = null; const errors: string[] = []; + let sessionReplies = 0; try { - await context.route("**/api/account/session", (route) => - route.fulfill({ + await context.route("**/api/account/session", async (route) => { + await route.fulfill({ + status: 200, json: { user: signedIn ? { @@ -39,8 +41,9 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) } : null, }, - }), - ); + }); + sessionReplies += 1; + }); await context.route( (url) => url.pathname === "/api/documents", async (route) => { @@ -94,11 +97,12 @@ export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) page.on("requestfinished", request => pending.delete(request.url())); page.on("requestfailed", request => {pending.delete(request.url());network.push(new URL(request.url()).pathname+": "+request.failure()?.errorText);}); async function navigate(url: string, reload = false) { - const session = page.waitForResponse(response => new URL(response.url()).pathname === "/api/account/session" && response.status() === 200); + const previousSessionReplies = sessionReplies; try { - const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]); + const navigation = await (reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"})); assert.ok(navigation?.ok(), "Navigation returns a successful document"); 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(); const server=await fetch(url,{signal:AbortSignal.timeout(3000)}).then(async response=>{await response.body?.cancel();return {status:response.status,elapsedMs:Date.now()-probeStarted}}).catch(error=>({error:error instanceof Error?error.name:"unknown",elapsedMs:Date.now()-probeStarted})); diff --git a/scripts/cleanup-integration.sh b/scripts/cleanup-integration.sh new file mode 100644 index 0000000..fce51a7 --- /dev/null +++ b/scripts/cleanup-integration.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +: "${GITEA_RUN_ID:?A numeric CI run ID is required}" +[[ "$GITEA_RUN_ID" =~ ^[1-9][0-9]*$ ]] || { echo 'Refusing invalid CI run ID' >&2; exit 2; } +project="prompt-ci-$GITEA_RUN_ID" +filter="label=com.docker.compose.project=$project" +# Recheck ownership immediately before removal; never target a shared stack. +containers=$(docker ps -aq --filter "$filter") +for resource in $containers; do + [[ "$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker rm -f "$resource" +done +volumes=$(docker volume ls -q --filter "$filter") +for resource in $volumes; do + [[ "$(docker volume inspect --format '{{index .Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker volume rm "$resource" +done +networks=$(docker network ls -q --filter "$filter") +for resource in $networks; do + [[ "$(docker network inspect --format '{{index .Labels "com.docker.compose.project"}}' "$resource")" == "$project" ]] || { echo "Refusing foreign resource: $resource" >&2; exit 2; } + docker network rm "$resource" +done diff --git a/scripts/integration.sh b/scripts/integration.sh index d08dad7..feab31e 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -2,7 +2,8 @@ set -Eeuo pipefail : "${WATERFALL_WIKI_IMAGE:?Build the runtime image first}" : "${WATERFALL_WIKI_TEST_IMAGE:?Build the integration image first}" -export COMPOSE_PROJECT_NAME="prompt-ci-${GITEA_RUN_ID:-$$}" +export GITEA_RUN_ID="${GITEA_RUN_ID:-$$}" +export COMPOSE_PROJECT_NAME="prompt-ci-$GITEA_RUN_ID" export PG_PORT=0 S3_PORT=0 WEB_PORT=0 WEB_BIND_ADDRESS=127.0.0.1 TEST_ENV=$(mktemp) chmod 0600 "$TEST_ENV" @@ -13,8 +14,19 @@ if [[ -n "${STUDIO_REVIEW_IMAGE:-}" ]]; then STUDIO_SERVICES+=(prompt-studio) fi compose(){ docker compose -p "$COMPOSE_PROJECT_NAME" --env-file "$TEST_ENV" "${COMPOSE_FILES[@]}" "$@"; } -cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall-wiki waterfall-wiki-worker "${STUDIO_SERVICES[@]}"; fi; compose down -v --remove-orphans; rm -f "$TEST_ENV"; } +cleanup(){ + code=$? + trap - EXIT INT TERM + if [ "$code" -ne 0 ]; then 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" + if [ "$code" -ne 0 ]; then exit "$code"; fi + exit "$cleanup_code" +} trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM # Random credentials exist only for this disposable stack; no host Node required. docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" # Explicitly isolate legacy unauthenticated integration coverage; production fails closed. diff --git a/scripts/test-integration-cleanup.py b/scripts/test-integration-cleanup.py new file mode 100644 index 0000000..4964431 --- /dev/null +++ b/scripts/test-integration-cleanup.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Exercise cleanup ownership checks without a Docker daemon or build.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +SCRIPT = Path(__file__).with_name('cleanup-integration.sh') +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 os.environ.get('EMPTY_INVENTORY'): + sys.exit(0) +if args[0] == 'ps': + if os.environ.get('FAIL_LIST'): + sys.exit(1) + print('test-container') +elif args[:2] == ['volume', 'ls']: + print('test-volume') +elif args[:2] == ['network', 'ls']: + print('test-network') +elif 'inspect' in args: + print(os.environ.get('RESOURCE_OWNER', 'prompt-ci-605')) +''' + + +class CleanupTests(unittest.TestCase): + def run_cleanup(self, run_id='605', **extra): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + docker = root / 'docker' + docker.write_text(FAKE_DOCKER) + docker.chmod(0o700) + calls = root / 'calls' + env = {**os.environ, 'PATH': directory + os.pathsep + os.environ['PATH'], + 'GITEA_RUN_ID': run_id, 'DOCKER_CALLS': str(calls), **extra} + result = subprocess.run(['bash', str(SCRIPT)], env=env, capture_output=True) + return result.returncode, [json.loads(line) for line in calls.read_text().splitlines()] if calls.exists() else [] + + def test_removes_only_run_owned_resources_in_dependency_order(self): + code, calls = self.run_cleanup() + self.assertEqual(code, 0) + self.assertEqual([args for args in calls if 'rm' in args], [ + ['rm', '-f', 'test-container'], ['volume', 'rm', 'test-volume'], + ['network', 'rm', 'test-network']]) + for args in calls: + if '--filter' in args: + self.assertEqual(args[-1], 'label=com.docker.compose.project=prompt-ci-605') + self.assertEqual(sum('inspect' in args for args in calls), 3) + + def test_already_clean_stack_is_a_successful_noop(self): + code, calls = self.run_cleanup(EMPTY_INVENTORY='1') + self.assertEqual(code, 0) + self.assertEqual(len(calls), 3) + self.assertFalse(any('rm' in args for args in calls)) + + def test_invalid_or_production_project_never_contacts_docker(self): + for value in ['', 'prompt', '0', '-1', '605*', '605/../prompt']: + with self.subTest(value=value): + code, calls = self.run_cleanup(value) + self.assertNotEqual(code, 0) + self.assertEqual(calls, []) + + def test_foreign_label_prevents_removal(self): + code, calls = self.run_cleanup(RESOURCE_OWNER='prompt') + self.assertNotEqual(code, 0) + self.assertFalse(any('rm' in args for args in calls)) + + def test_inventory_failure_does_not_continue_with_deletion(self): + code, calls = self.run_cleanup(FAIL_LIST='1') + self.assertNotEqual(code, 0) + self.assertEqual(len(calls), 1) + + +if __name__ == '__main__': + unittest.main()