Compare commits

...

9 Commits

Author SHA1 Message Date
codex-bot 429e78c722 Run complete workspace UI assertions in an isolated context of the integration browser
creative workspace review checks / verify (pull_request) Successful in 23m26s
2026-10-30 19:37:59 +08:00
codex-bot 4faef8b766 Wait for visible application readiness in workspace UI regression checks
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 19:07:20 +08:00
codex-bot d8c06d6cc6 Capture document directory network and hydration failures in CI
creative workspace review checks / verify (pull_request) Failing after 19m53s
2026-10-30 18:37:02 +08:00
codex-bot cad84f8a67 Run nested UI checks and align authentication assertions with public workspace routes
creative workspace review checks / verify (pull_request) Failing after 19m41s
2026-10-30 18:13:58 +08:00
codex-bot 33405b7d8b Exercise document directory at its canonical entry in UI regression tests 2026-10-30 18:07:21 +08:00
codex-bot cfc6686904 Capture document client navigation failures in CI
creative workspace review checks / verify (pull_request) Failing after 20m32s
2026-10-30 17:44:05 +08:00
codex-bot 40a6076961 Wait for document navigation before asserting its rendered heading
creative workspace review checks / verify (pull_request) Failing after 17m59s
2026-10-30 17:18:42 +08:00
codex-bot 53a4103308 Sequence creative workspace releases and verify document discovery at its new entry
creative workspace review checks / verify (pull_request) Failing after 17m42s
2026-10-30 16:57:48 +08:00
Zayden-Jung ea7bbebb3e Merge pull request 'Redesign creative landing page, workspace and editor entry points' (#27) from codex/landing-workspace into main
studio CI and deployment / verify-and-deploy (push) Failing after 11m29s
waterfall-wiki CI and deployment / verify-and-deploy (push) Failing after 19m46s
Reviewed-on: #27
2026-10-30 07:17:43 +00:00
11 changed files with 412 additions and 397 deletions
+13 -4
View File
@@ -5,14 +5,15 @@ on:
paths:
- 'apps/studio/**'
- 'apps/waterfall-wiki/**'
- '.gitea/workflows/creative-workspace.yml'
- '.gitea/workflows/**'
- 'scripts/*studio*'
concurrency:
group: creative-workspace-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs:
verify:
runs-on: ces-control
timeout-minutes: 45
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
@@ -21,5 +22,13 @@ jobs:
run: git diff --check HEAD^ HEAD
- name: Verify Studio types, tests, build and smoke
run: docker build -f apps/studio/Dockerfile -t "creative-studio-review:${{ gitea.sha }}" .
- name: Verify Wiki types, tests, build and smoke
run: docker build --target build -f apps/waterfall-wiki/Dockerfile -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Build Wiki integration and runtime images
run: |
docker build --target integration -t "creative-wiki-tests:${{ gitea.sha }}" .
docker build --target runtime -t "creative-wiki-review:${{ gitea.sha }}" .
- name: Verify isolated PostgreSQL, S3 and browser integration
env:
WATERFALL_WIKI_IMAGE: creative-wiki-review:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: creative-wiki-tests:${{ gitea.sha }}
GITEA_RUN_ID: ${{ gitea.run_id }}
run: bash scripts/integration.sh
-51
View File
@@ -1,51 +0,0 @@
name: studio CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/studio/**'
- 'scripts/deploy-studio.sh'
- 'scripts/verify-studio-production.sh'
- 'scripts/verify-studio-library.mjs'
- 'scripts/verify-studio-visual.cjs'
- '.gitea/workflows/studio.yml'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- 'pnpm-workspace.yaml'
jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Release studio
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Verify the deployed Studio API and native pages
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
+30 -2
View File
@@ -1,9 +1,13 @@
name: waterfall-wiki CI and deployment
name: creative workspace CI and deployment
on:
push:
branches: [main]
paths:
- 'apps/waterfall-wiki/**'
- 'apps/studio/**'
- 'scripts/*studio*'
- 'content/studio-examples/**'
- 'content/studio-tools/layouts-api.md'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
- 'compose.yml'
@@ -18,11 +22,19 @@ jobs:
verify-and-deploy:
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
runs-on: ces-control
timeout-minutes: 45
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check shared content and documentation contracts
run: |
node scripts/check-studio-contracts.mjs
node --check scripts/export-studio-series.cjs
node --check scripts/save-studio-series.mjs
bash -n scripts/publish-studio-guide.sh scripts/deliver-studio-series.sh
- name: Build and verify Studio
run: docker build -f apps/studio/Dockerfile -t "prompt-studio:${{ gitea.sha }}" .
- name: Build, verify, deploy and retain wiki images
env:
RELEASE_SHA: ${{ gitea.sha }}
@@ -32,3 +44,19 @@ jobs:
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
- name: Release Studio after Wiki routes are deployed
run: bash scripts/deploy-studio.sh "${{ gitea.sha }}"
- name: Verify the deployed creative workspace
run: bash scripts/verify-studio-production.sh
- name: Save server acceptance evidence
id: acceptance_evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: studio-production-acceptance
path: artifacts/studio-acceptance/
if-no-files-found: ignore
retention-days: 7
- name: Remove temporary acceptance output after upload
if: always() && steps.acceptance_evidence.outcome == 'success'
run: rm -rf artifacts/studio-acceptance
+2 -2
View File
@@ -81,14 +81,14 @@ bash -n scripts/deploy.sh scripts/integration.sh scripts/deploy-studio.sh
## 部署与持久化
Gitea 工作流按应用路径触发:`waterfall-wiki.yml`、`studio.yml` 和 `python.yml`。waterfall-wiki 完成类型检查、单元测试、生产构建、冒烟和真实 PostgreSQL/S3 集成测试后部署;Studio 独立构建和部署。文档与作者数据本身不触发应用部署。
Web 应用由 `waterfall-wiki.yml` 统一发布,两个应用的路径都能触发它:先检查并构建 Studio,再完成 Wiki 的类型检查、单元测试、生产构建、冒烟及隔离 PostgreSQL/S3 集成测试并部署 Wiki,随后部署 Studio,最后在指定服务器验收。顺序在同一个 ces-control 作业内保证,避免独立作业互相等待。PR 检查也运行隔离集成测试,但不部署。Python 检查仍由 `python.yml` 承担;文档与作者数据本身不触发应用部署。
| 服务 | 入口/端口 |
| --- | --- |
| `waterfall-wiki` | 公网 `18131`,也是 Studio 的统一入口 |
| PostgreSQL | `127.0.0.1:18132` |
| 对象存储 | `127.0.0.1:18133` |
| Studio | `127.0.0.1:18134`;公网通过 `/studio` 代理 |
| Studio | `127.0.0.1:18134`;编辑器公网入口为 `/graphic`、`/infinite_canvas`,资源和 API 保留 `/studio` 代理 |
| Python | 无网络端口,按需运行 |
生产 Compose 项目仍为 `prompt`,部署根目录仍为 `/opt/prompt`。既有 `prompt_postgres_data`、`prompt_objects`、`prompt_auth_data` 卷与数据库身份保留;应用更名不会重新创建一套空存储。Compose 应用服务改为 `waterfall-wiki` 和 `waterfall-wiki-worker`,镜像参数为 `WATERFALL_WIKI_IMAGE` / `WATERFALL_WIKI_TEST_IMAGE`。
+4 -2
View File
@@ -47,13 +47,15 @@ async function main() {
if (command === 'verify' || command === 'exercise') {
const request = (path:string,init:RequestInit={}) => fetch(base+path,{...init,redirect:'manual'});
const buildId = readFileSync('.next/BUILD_ID','utf8').trim();
assert.equal((await request('/')).status,200,'Public knowledge homepage');
for(const path of ['/','/workspace','/documents','/account'])assert.equal((await request(path)).status,200,'Public shell '+path);
const studio=await request('/studio');assert.equal(studio.status,307);assert.equal(studio.headers.get('location'),'/workspace');
for(const [from,to] of [['/studio/graphic','/graphic'],['/studio/infinite_canvas','/infinite_canvas']]){const r=await request(from);assert.equal(r.status,308);assert.equal(r.headers.get('location'),to)}
assert.equal((await request('/account')).status,200,'Public account page');
const homeData = await request(`/_next/data/${buildId}/index.json`);
assert.ok([200,404].includes(homeData.status),'Public homepage data is readable or absent for static pages');
assert.ok(!isLoginRedirect(homeData.headers.get('location') || homeData.headers.get('x-nextjs-redirect'),base));
assert.equal((await request('/api/posts',{method:'POST',headers:{Cookie:'wiki_account='+ 'a'.repeat(64)},body:'{}'})).status,401,'Account cookies never grant admin rights');
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio',`/_next/data/${buildId}/library/import.json`]) {
for (const path of ['/library/import','/skills/shadow-puppet/egret-example.png','/studio/layouts/ref-01',`/_next/data/${buildId}/library/import.json`]) {
const response = await request(path); assert.equal(response.status,307,path); const destination = response.headers.get('location') || response.headers.get('x-nextjs-redirect'); assert.ok(isLoginRedirect(destination,base),path+' must redirect to login: '+destination);
}
for (const path of ['/api/feed','/api/upload','/studio/api/export/test','/api/media/missing']) assert.equal((await request(path)).status,401,path);
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import {verifyGuidanceWorkspaceUI} from './guidance-workspace-ui';
import {chromium} from 'playwright-core';
import {randomUUID} from 'node:crypto';
import {newToken,tokenDigest,ACCOUNT_COOKIE} from '../src/lib/account-crypto';
@@ -30,12 +31,22 @@ export async function verifyGuidancePublishing(base:string){
const own=await fetch(base+'/api/documents?author='+owner).then(r=>r.json());assert.ok(own.documents.some((d:{id:string})=>d.id===httpDoc.id));assert.ok(own.documents.every((d:{author:string})=>d.author===owner));assert.equal((await fetch(base+'/api/documents?author=invalid')).status,400);
const xml=await fetch(base+'/api/documents/'+httpDoc.id+'?revision=1&format=xml');assert.equal(xml.status,200);assert.match(await xml.text(),/<tag>往返标签<\/tag>/);
const page=await fetch(base+'/documents/'+httpDoc.id+'?revision=1');assert.equal(page.status,200);assert.ok((await page.text()).includes('实际数据库文档'));
// The homepage and discovery list hydrate from the real API, so verify them in Chromium on the CI runner.
const directory=await fetch(base+'/api/documents');assert.equal(directory.status,200);assert.ok((await directory.json()).documents.some((d:{id:string})=>d.id===httpDoc.id),'Published document must appear in the public API');
// The document discovery list hydrates from the real API; the homepage is the product landing page.
const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
try{
const page=await browser.newPage();
const navigationDiagnostics:string[]=[],inflight=new Set<string>();
const record=(value:string)=>{navigationDiagnostics.push(value.slice(0,1800));if(navigationDiagnostics.length>24)navigationDiagnostics.shift();};
page.on('pageerror',error=>record('pageerror: '+error.message));
page.on('console',message=>{if(message.type()==='error')record('console: '+message.text());});
page.on('request',request=>inflight.add(request.url()));
page.on('requestfinished',request=>inflight.delete(request.url()));
page.on('requestfailed',request=>{inflight.delete(request.url());record('requestfailed: '+new URL(request.url()).pathname+' '+request.failure()?.errorText)});
page.on('response',response=>{const path=new URL(response.url()).pathname;if(path.startsWith('/api/')||path.includes('/_next/data/'))void response.text().then(body=>record(`data ${response.status()} ${path}: ${body.slice(0,1400)}`)).catch(()=>{});else if(response.status()>=400||response.request().resourceType()==='script')record(`resource ${response.status()} ${path}`);});
try{
const link=`a[href="/documents/${httpDoc.id}?revision=1"]`;
await page.goto(base+'/',{waitUntil:'domcontentloaded'});
const navigation=await page.goto(base+'/documents',{waitUntil:'domcontentloaded'});record('directory HTTP '+navigation?.status());
await page.locator(link).waitFor({state:'visible'});assert.equal(await page.locator(link).getByRole('heading').textContent(),httpDoc.title);
// Published matches must not inherit the examples' empty state; submitted filters survive reload.
const search=page.getByRole('textbox',{name:'搜索文档'});
@@ -54,8 +65,15 @@ export async function verifyGuidancePublishing(base:string){
await page.locator(link).waitFor({state:'visible'});
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();await page.locator('h1').waitFor();assert.equal(await page.locator('h1').textContent(),httpDoc.title);
await page.locator(link).click();
await page.waitForURL(url=>url.pathname==='/documents/'+httpDoc.id&&url.searchParams.get('revision')==='1');
await page.getByRole('heading',{name:httpDoc.title,exact:true,level:1}).waitFor();
assert.equal(await page.locator('h1').textContent(),httpDoc.title);
await page.goto(base+'/write',{waitUntil:'domcontentloaded'});assert.equal(await page.locator('h1').textContent(),'发布独立文档');
}catch(error){console.error('Document navigation diagnostics',JSON.stringify({url:page.url(),headings:await page.locator('h1').allTextContents(),body:(await page.locator('body').innerText()).slice(0,2200),inflight:[...inflight].map(url=>new URL(url).pathname),events:navigationDiagnostics}));throw error;}
await page.close();
await verifyGuidanceWorkspaceUI(base,browser);
console.log('Workspace desktop/mobile, filters, reading, account and publishing assertions passed in a fresh browser context');
}finally{await browser.close();}
assert.equal((await request('/'+httpDoc.id,'DELETE',undefined,{cookie:''})).status,401);
assert.equal((await request('/'+httpDoc.id,'DELETE')).status,200);
@@ -0,0 +1,325 @@
/** UI fixtures run in a fresh context; real storage and authentication have separate coverage. */
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import type { Browser } from "playwright-core";
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
const owner = "11111111-1111-4111-8111-111111111111";
const authored: Guidance = {
id: "user-" + owner + "-review",
revision: 1,
kind: "guide",
title: "准备一次清晰的代码审查",
summary: "用明确的问题和证据,完成一次有用的代码审查。",
applies: "提交合并请求前",
body: "先理解改动目标,再核对实现与验证。",
steps: [],
checks: [],
links: [],
tags: [],
};
export async function verifyGuidanceWorkspaceUI(base: string, browser: Browser) {
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
let signedIn = false,
previewError = false,
requestedAuthor: string | null = null,
published: Guidance | null = null;
const errors: string[] = [];
try {
await context.route("**/api/account/session", (route) =>
route.fulfill({
json: {
user: signedIn
? {
id: owner,
email: "editor@example.com",
verified_at: "2026-10-29T00:00:00Z",
}
: null,
},
}),
);
await context.route(
(url) => url.pathname === "/api/documents",
async (route) => {
if (route.request().method() === "GET") {
requestedAuthor = new URL(route.request().url()).searchParams.get(
"author",
);
return route.fulfill({
json: { documents: [{ ...authored, author: owner }] },
});
}
published = documentInput.parse(route.request().postDataJSON());
return route.fulfill({ status: 201, json: { document: published } });
},
);
await context.route("**/api/documents/preview", (route) => {
if (previewError)
return route.fulfill({
status: 400,
json: { error: "输入不符合协议", fields: ["applies"] },
});
let document: Guidance;
try {
document =
route.request().headers()["content-type"] === "application/xml"
? parseDocumentXml(route.request().postData() || "")
: documentInput.parse(route.request().postDataJSON());
} catch {
return route.fulfill({
status: 400,
json: { error: "输入不符合协议" },
});
}
return route.fulfill({
json: {
document,
xml: guidanceXml(document),
links: document.links.map((link) => ({ ...link, resolved: true })),
},
});
});
await context.route("**/api/documents/" + authored.id, (route) =>
route.fulfill({ json: { document: authored } }),
);
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on("pageerror", (error) => errors.push(error.message));
page.on("crash", () => errors.push("Browser renderer crashed"));
const pending = new Set<string>(), network: string[] = [];
page.on("request", request => pending.add(request.url()));
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);
try {
const [navigation] = await Promise.all([reload ? page.reload({waitUntil:"domcontentloaded"}) : page.goto(url,{waitUntil:"domcontentloaded"}),session]);
assert.ok(navigation?.ok(), "Navigation returns a successful document");
await page.getByText(signedIn ? "editor@example.com" : "登录你的账户",{exact:true}).first().waitFor({state:"attached"});
} 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}));
console.error("Workspace UI navigation diagnostics",JSON.stringify({url:page.url(),browserConnected:browser.isConnected(),server,pending:[...pending].map(value=>new URL(value).pathname),network:network.slice(-20),errors,body:(await page.locator("body").innerText().catch(()=>"unavailable")).slice(0,1600)}));
throw error;
}
}
await navigate(base + "/documents");
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(await page.title(), "社区文档 · 织页");
const search = page.getByRole("textbox", { name: "搜索文档" });
await search.fill("不存在的文档");
await search.press("Enter");
await page
.getByRole("heading", { name: "没有匹配文档", exact: true })
.waitFor();
await navigate(page.url(), true);
await page.getByRole("heading",{name:"没有匹配文档",exact:true}).waitFor();
assert.equal(await search.inputValue(), "不存在的文档");
await page
.getByRole("button", { name: "清除所有筛选", exact: true })
.click();
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.getByRole("tab", { name: /事实依据/ }).focus();
await page.keyboard.press("Home");
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
fullPage: true,
});
}
await navigate(base + "/guide/review-changes?revision=1");
await page.getByRole("tab", { name: "XML 源文档" }).click();
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
await page.getByRole("tab", { name: /版本记录/ }).click();
await page.getByText("当前阅读", { exact: true }).waitFor();
await page.getByRole("tab", { name: "文档概览" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
fullPage: true,
});
await navigate(base + "/account");
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
await page
.getByLabel("密码", { exact: true })
.fill("a-long-example-password");
await page.getByRole("button", { name: "显示密码" }).click();
assert.equal(
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
"text",
);
await page.getByRole("button", { name: "隐藏密码" }).click();
await page.getByLabel("密码", { exact: true }).fill("");
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
fullPage: true,
});
await navigate(base + "/write");
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
signedIn = true;
await navigate(page.url(), true);
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
await page
.getByLabel("一句话摘要", { exact: false })
.fill("可独立使用的指导");
await page
.getByLabel("适用条件", { exact: false })
.fill("检查界面流程时");
await page
.getByLabel("正文", { exact: false })
.fill("先判断适用条件,再阅读正文。");
await page
.getByLabel("建议步骤", { exact: true })
.fill("第一步\n\n第二步");
previewError = true;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
previewError = false;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("dialog").waitFor({ state: "visible" });
await page.getByRole("button", { name: "返回编辑" }).click();
assert.equal(
await page.getByLabel("正文", { exact: false }).inputValue(),
"先判断适用条件,再阅读正文。",
);
await page.getByRole("button", { name: "添加条件链接" }).click();
await page.getByRole("button", { name: "移除关联文档 1" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
fullPage: true,
});
}
await page.getByRole("button", { name: "发布文档", exact: true }).click();
await page
.getByRole("status")
.filter({ hasText: "已公开发布" })
.waitFor();
assert.ok(published);
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
assert.equal(
(published as Guidance).id,
"user-" + owner + "-workspace-test",
);
await navigate(base + "/documents?scope=mine");
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(requestedAuthor, owner);
await navigate(base + "/write?id=" + authored.id);
await page.waitForFunction(title=>Array.from(document.querySelectorAll<HTMLInputElement>("input")).some(input=>input.value===title),authored.title);
assert.equal(
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
true,
);
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
authored.title,
);
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
const xmlInput = page.getByLabel("XML 输入");
assert.match(await xmlInput.inputValue(), /revision="2"/);
await xmlInput.fill(
(await xmlInput.inputValue()).replace(
authored.title,
"通过 XML 更新的文档",
),
);
await page
.getByRole("button", { name: "可视化编辑", exact: true })
.click();
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
"通过 XML 更新的文档",
);
await page.setViewportSize({ width: 390, height: 844 });
await navigate(base + "/documents");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.getByRole("button", { name: "打开导航" }).click();
await page
.getByRole("dialog", { name: "工作区导航" })
.waitFor({ state: "visible" });
await page.keyboard.press("Escape");
assert.equal(
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
false,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
fullPage: true,
});
await navigate(base + "/guide/review-changes?revision=1");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
fullPage: true,
});
await navigate(base + "/account");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await navigate(base + "/write");
await page.getByLabel("文档短名", { exact: true }).waitFor();
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
await page.getByLabel("XML 输入").waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
fullPage: true,
});
await navigate(base + "/protocol");
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
fullPage: true,
});
assert.deepEqual(errors, []);
} finally {
await context.close();
}
}
@@ -1,326 +1,13 @@
/** UI contract checks use isolated API fixtures; real storage/auth remain covered by guidance-integration.ts. */
import assert from "node:assert/strict";
import { test } from "node:test";
import { mkdir } from "node:fs/promises";
import { chromium } from "playwright-core";
import { documentInput, parseDocumentXml } from "../src/lib/guidance-input";
import { guidanceXml, type Guidance } from "../src/content/guides/catalog";
import { verifyGuidanceWorkspaceUI } from "./guidance-workspace-ui";
const base = process.env.WORKSPACE_TEST_URL;
const owner = "11111111-1111-4111-8111-111111111111";
const authored: Guidance = {
id: "user-" + owner + "-review",
revision: 1,
kind: "guide",
title: "准备一次清晰的代码审查",
summary: "用明确的问题和证据,完成一次有用的代码审查。",
applies: "提交合并请求前",
body: "先理解改动目标,再核对实现与验证。",
steps: [],
checks: [],
links: [],
tags: [],
};
test(
"workspace filters, reading tabs, account states and editor work on desktop and mobile",
{ skip: !base },
async () => {
const browser = await chromium.launch({
executablePath:
process.env.CHROMIUM_PATH ||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
args: ["--no-sandbox"],
});
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
let signedIn = false,
previewError = false,
requestedAuthor: string | null = null,
published: Guidance | null = null;
const errors: string[] = [];
try {
await context.route("**/api/account/session", (route) =>
route.fulfill({
json: {
user: signedIn
? {
id: owner,
email: "editor@example.com",
verified_at: "2026-10-29T00:00:00Z",
}
: null,
},
}),
);
await context.route(
(url) => url.pathname === "/api/documents",
async (route) => {
if (route.request().method() === "GET") {
requestedAuthor = new URL(route.request().url()).searchParams.get(
"author",
);
return route.fulfill({
json: { documents: [{ ...authored, author: owner }] },
});
}
published = documentInput.parse(route.request().postDataJSON());
return route.fulfill({ status: 201, json: { document: published } });
},
);
await context.route("**/api/documents/preview", (route) => {
if (previewError)
return route.fulfill({
status: 400,
json: { error: "输入不符合协议", fields: ["applies"] },
});
let document: Guidance;
try {
document =
route.request().headers()["content-type"] === "application/xml"
? parseDocumentXml(route.request().postData() || "")
: documentInput.parse(route.request().postDataJSON());
} catch {
return route.fulfill({
status: 400,
json: { error: "输入不符合协议" },
});
}
return route.fulfill({
json: {
document,
xml: guidanceXml(document),
links: document.links.map((link) => ({ ...link, resolved: true })),
},
});
});
await context.route("**/api/documents/" + authored.id, (route) =>
route.fulfill({ json: { document: authored } }),
);
const page = await context.newPage();
page.setDefaultTimeout(10000);
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(base!, { waitUntil: "networkidle" });
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
assert.equal(await page.title(), "探索文档 · 织页");
const search = page.getByRole("textbox", { name: "搜索文档" });
await search.fill("不存在的文档");
await search.press("Enter");
await page
.getByRole("heading", { name: "没有匹配文档", exact: true })
.waitFor();
await page.reload({ waitUntil: "networkidle" });
assert.equal(await search.inputValue(), "不存在的文档");
await page
.getByRole("button", { name: "清除所有筛选", exact: true })
.click();
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.getByRole("tab", { name: /事实依据/ }).focus();
await page.keyboard.press("Home");
await page.getByRole("tab", { name: /全部文档/ }).waitFor();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await mkdir(process.env.WORKSPACE_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/directory.png",
fullPage: true,
});
}
await page.goto(base + "/guide/review-changes?revision=1", {
waitUntil: "networkidle",
});
await page.getByRole("tab", { name: "XML 源文档" }).click();
assert.match(await page.locator("pre").innerText(), /linked-guidance/);
await page.getByRole("tab", { name: /版本记录/ }).click();
await page.getByText("当前阅读", { exact: true }).waitFor();
await page.getByRole("tab", { name: "文档概览" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/reader.png",
fullPage: true,
});
await page.goto(base + "/account", { waitUntil: "networkidle" });
await page.getByRole("button", { name: "创建账户", exact: true }).click();
await page.getByRole("heading", { name: "创建你的账户" }).waitFor();
await page
.getByLabel("密码", { exact: true })
.fill("a-long-example-password");
await page.getByRole("button", { name: "显示密码" }).click();
assert.equal(
await page.getByLabel("密码", { exact: true }).getAttribute("type"),
"text",
);
await page.getByRole("button", { name: "隐藏密码" }).click();
await page.getByLabel("密码", { exact: true }).fill("");
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/account.png",
fullPage: true,
});
await page.goto(base + "/write", { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "登录后开始编写" }).waitFor();
signedIn = true;
await page.reload({ waitUntil: "networkidle" });
await page.getByLabel("文档短名", { exact: true }).fill("workspace-test");
await page.getByLabel("标题", { exact: false }).fill("界面回归验证文档");
await page
.getByLabel("一句话摘要", { exact: false })
.fill("可独立使用的指导");
await page
.getByLabel("适用条件", { exact: false })
.fill("检查界面流程时");
await page
.getByLabel("正文", { exact: false })
.fill("先判断适用条件,再阅读正文。");
await page
.getByLabel("建议步骤", { exact: true })
.fill("第一步\n\n第二步");
previewError = true;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("alert").filter({ hasText: "适用条件" }).waitFor();
previewError = false;
await page
.getByRole("button", { name: "校验并预览", exact: true })
.click();
await page.getByRole("dialog").waitFor({ state: "visible" });
await page.getByRole("button", { name: "返回编辑" }).click();
assert.equal(
await page.getByLabel("正文", { exact: false }).inputValue(),
"先判断适用条件,再阅读正文。",
);
await page.getByRole("button", { name: "添加条件链接" }).click();
await page.getByRole("button", { name: "移除关联文档 1" }).click();
if (process.env.WORKSPACE_SCREENSHOT_DIR) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/editor.png",
fullPage: true,
});
}
await page.getByRole("button", { name: "发布文档", exact: true }).click();
await page
.getByRole("status")
.filter({ hasText: "已公开发布" })
.waitFor();
assert.ok(published);
assert.deepEqual((published as Guidance).steps, ["第一步", "第二步"]);
assert.equal(
(published as Guidance).id,
"user-" + owner + "-workspace-test",
);
await page.goto(base + "/documents?scope=mine", {
waitUntil: "networkidle",
});
assert.equal(requestedAuthor, owner);
await page
.getByRole("heading", { name: authored.title, exact: true })
.waitFor();
await page.goto(base + "/write?id=" + authored.id, {
waitUntil: "networkidle",
});
assert.equal(
await page.getByLabel("文档短名", { exact: true }).isDisabled(),
true,
);
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
authored.title,
);
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
const xmlInput = page.getByLabel("XML 输入");
assert.match(await xmlInput.inputValue(), /revision="2"/);
await xmlInput.fill(
(await xmlInput.inputValue()).replace(
authored.title,
"通过 XML 更新的文档",
),
);
await page
.getByRole("button", { name: "可视化编辑", exact: true })
.click();
assert.equal(
await page.getByLabel("标题", { exact: false }).inputValue(),
"通过 XML 更新的文档",
);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(base!, { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.getByRole("button", { name: "打开导航" }).click();
await page
.getByRole("dialog", { name: "工作区导航" })
.waitFor({ state: "visible" });
await page.keyboard.press("Escape");
assert.equal(
await page.getByRole("dialog", { name: "工作区导航" }).isVisible(),
false,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-directory.png",
fullPage: true,
});
await page.goto(base + "/guide/review-changes?revision=1", {
waitUntil: "networkidle",
});
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-reader.png",
fullPage: true,
});
await page.goto(base + "/account", { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
await page.goto(base + "/write", { waitUntil: "networkidle" });
await page.getByLabel("文档短名", { exact: true }).waitFor();
await page.getByRole("button", { name: "协议 XML", exact: true }).click();
await page.getByLabel("XML 输入").waitFor();
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-editor.png",
fullPage: true,
});
await page.goto(base + "/protocol", { waitUntil: "networkidle" });
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
if (process.env.WORKSPACE_SCREENSHOT_DIR)
await page.screenshot({
path: process.env.WORKSPACE_SCREENSHOT_DIR + "/mobile-protocol.png",
fullPage: true,
});
assert.deepEqual(errors, []);
} finally {
await context.close();
await browser.close();
}
},
);
test("workspace filters, reading tabs, account states and editor work on desktop and mobile", {skip: !base}, async () => {
const browser = await chromium.launch({
executablePath: process.env.CHROMIUM_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
try { await verifyGuidanceWorkspaceUI(base!, browser); }
finally { await browser.close(); }
});
@@ -33,8 +33,6 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi
for(const route of ['/','/images','/videos','/audio','/articles','/motion','/library/import','/tools/doodle','/tools/skills','/tools/skills/street-people','/tools/skills/shadow-puppet'])assert.equal((await fetch(base+route)).status,200,route);
await verifyEmailAccounts();
await verifyGuidancePublishing(base);
const workspaceUI=spawn(process.execPath,['--import','tsx','--test','tests/guidance-workspace.test.ts'],{env:{...process.env,WORKSPACE_TEST_URL:base},timeout:90000,stdio:'inherit'});
const [workspaceExit]=await once(workspaceUI,'exit');assert.equal(workspaceExit,0,'SaaS workspace UI regression checks');
await verifyFlatNoteMigration();
const root=await request('/api/tags',{name:'线条',slug:'lines'});const child=await request('/api/tags',{name:'白色',slug:'white'});
const sameTag=await request('/api/tags',{name:'#线条'});assert.equal(sameTag.id,root.id,'Hashtag creation reuses the globally unique tag');
+1 -1
View File
@@ -8,7 +8,7 @@ flock -w 300 9
latest=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$latest" != "$sha" ]; then
git -c safe.directory="$PWD" fetch origin main
git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/studio.yml pnpm-workspace.yaml || exit 0
git -c safe.directory="$PWD" diff --quiet "$sha" origin/main -- apps/studio scripts/deploy-studio.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml || exit 0
fi
mkdir -p /opt/prompt-studio
previous=$(cat /opt/prompt-studio/image 2>/dev/null || true)
+6 -7
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash
# Run on ces-control after deployment; never starts a preview web server.
set -Eeuo pipefail
# Wiki and Studio deploy independently; wait for Wiki's canonical route proxy.
canonical_ready=false
for attempt in {1..120}; do
if curl -fsS http://8.211.154.134:18131/ | grep -q '想法不该停在对话里'; then canonical_ready=true; break; fi
sleep 5
done
$canonical_ready || { echo 'Wiki landing and canonical route release is not ready'; exit 1; }
# The shared release job deploys Wiki before Studio; a missing route is an error,
# not a reason to occupy this runner while another queued job tries to deploy it.
if ! curl -fsS http://8.211.154.134:18131/ | grep -q '想法不该停在对话里'; then
echo 'The shared release has not deployed the Wiki landing and canonical routes'
exit 1
fi
REPO_ROOT=$(git rev-parse --show-toplevel)
RESULT_DIR="$REPO_ROOT/artifacts/studio-acceptance"
MODE=${1:-verify}