Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6508e23
fix(a11y): expose async button busy states
seonghobae Aug 15, 2026
a5c8414
docs(a11y): record async busy-state evidence
seonghobae Aug 15, 2026
c85a871
ci: repair document action busy identity
seonghobae Aug 15, 2026
ffa2f69
ci: verify document action busy identity without new test deps
seonghobae Aug 15, 2026
026ca37
fix(a11y): identify the active document action
github-actions[bot] Aug 15, 2026
cdd96f7
chore(ci): revalidate active document action accessibility
seonghobae Aug 15, 2026
0eff0c4
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 15, 2026
537bcdb
test(a11y): avoid dynamic regexp in busy-state evidence
seonghobae Aug 15, 2026
f969010
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 17, 2026
b1a9762
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 17, 2026
87d2d47
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 18, 2026
491c52a
Merge remote-tracking branch 'origin/develop' into HEAD
seonghobae Aug 20, 2026
65ab8cb
test: type document action busy-state props
seonghobae Aug 20, 2026
8b7731d
Merge branch 'develop' into fix/aria-busy-clean-scope
opencode-agent[bot] Aug 23, 2026
eb8af38
Merge branch 'develop' into fix/aria-busy-clean-scope
seonghobae Aug 26, 2026
1b46593
fix(a11y): distinguish review submission from evidence loading
seonghobae Sep 6, 2026
43be57e
fix(projects): stop claiming unsaved evidence notes are persisted
seonghobae Sep 6, 2026
67fed84
Merge protected develop into aria-busy accessibility repair
seonghobae Sep 6, 2026
e11a8e9
fix(session): require explicit authenticated response claims
seonghobae Sep 6, 2026
6abe074
fix(projects): distinguish unavailable sources from empty data
seonghobae Sep 6, 2026
0b28f8e
fix(projects): preserve progress evidence boundaries
seonghobae Sep 7, 2026
517e560
merge: preserve protected attachment repair in project evidence work
seonghobae Sep 7, 2026
9a8c614
test(a11y): distinguish evidence fetch from save busy state
seonghobae Sep 7, 2026
aa6b618
test(a11y): hold document action lock through refresh
seonghobae Sep 7, 2026
0809249
fix(a11y): retain document action lock through refresh
seonghobae Sep 7, 2026
40fcc7c
fix(a11y): scope evidence review busy state to save
seonghobae Sep 7, 2026
010facb
docs(a11y): record busy-state lifecycle invariants
seonghobae Sep 7, 2026
15ed98a
test(a11y): preserve active status on rejected reentry
seonghobae Sep 7, 2026
c45ed60
fix(a11y): guard document reentry before validation
seonghobae Sep 7, 2026
8c9418d
fix(data): 문서 요청과 목록 갱신 상태를 분리
seonghobae Sep 7, 2026
ff6f82c
merge: 원격 접근성 수리와 문서 갱신 보호를 통합
seonghobae Sep 7, 2026
215db67
merge: 원격 재진입 보호 수정을 보존
seonghobae Sep 7, 2026
cc30ba6
fix: 동일 품질 화면의 반복 DOM 읽기를 제거
seonghobae Sep 7, 2026
1b8497f
fix: 프로젝트 smoke 응답의 필수 계약을 복원
seonghobae Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
422 changes: 422 additions & 0 deletions docs/doctoring/async-button-busy-state.md

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions frontend/scripts/full-product-project-contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/* @vitest-environment jsdom */
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, expect, it, vi } from "vitest";
import { installRoutes } from "./full-product-ui-smoke.mjs";
import { ProjectsLayout } from "../src/components/ProjectsLayout";

vi.mock("next/link", () => ({
default: ({ children, ...props }) => React.createElement("a", props, children),
}));

let renderRoot;
let renderContainer;

afterEach(async () => {
if (renderRoot) await act(async () => renderRoot.unmount());
renderRoot = undefined;
renderContainer?.remove();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

async function registeredResponse(endpointPath) {
const routeHandlers = new Map();
await installRoutes({ route: async (routePattern, routeHandler) => {
routeHandlers.set(routePattern, routeHandler);
} });
let responseBody;
const selectedHandler = routeHandlers.get(endpointPath === "/auth/session" ? "**/auth/session" : "**/api/**");
await selectedHandler({
request: () => ({ url: () => `http://127.0.0.1:3001${endpointPath}`, method: () => "GET" }),
fulfill: async (responseValue) => { responseBody = JSON.parse(responseValue.body); },
});
return responseBody;
}

it("declares the authenticated session contract explicitly", async () => {
expect(await registeredResponse("/auth/session")).toMatchObject({
authenticated: true, claims: { userId: "smoke-user" },
});
});

it("supplies creation timestamps for every returned task", async () => {
const taskRows = await registeredResponse("/api/tasks");
expect(taskRows).toHaveLength(3);
for (const taskRow of taskRows) expect(taskRow.created_at).toEqual(expect.any(String));
});

it("returns a candidate collection instead of generic placeholder success", async () => {
expect(await registeredResponse("/api/projects/candidates")).toEqual({ candidates: [] });
});

it("renders actual project readiness from the registered unit responses", async () => {
vi.stubGlobal("fetch", vi.fn(async (requestPath) => ({
ok: true, status: 200,
json: async () => registeredResponse(String(requestPath)),
})));
renderContainer = document.createElement("div");
document.body.appendChild(renderContainer);
renderRoot = createRoot(renderContainer);
await act(async () => renderRoot.render(React.createElement(ProjectsLayout)));
expect(renderContainer.textContent).not.toContain("프로젝트 근거를 불러오지 못했습니다");
expect(Array.from(renderContainer.querySelectorAll("a")).some((linkElement) =>
linkElement.textContent === "관련 문서/메일 연결",
)).toBe(true);
});
23 changes: 15 additions & 8 deletions frontend/scripts/full-product-ui-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ const task = {
source_type: "email",
source_email_id: String(sourceEmail.id),
related_thread_id: sourceEmail.thread_id,
created_at: "2026-07-02T05:00:00Z",
updated_at: "2026-07-02T05:00:00Z",
};

Expand All @@ -369,6 +370,7 @@ const knowledgeTask = {
source_type: "self_sent_knowledge",
source_email_id: String(sourceEmail.id),
related_thread_id: sourceEmail.thread_id,
created_at: "2026-07-02T05:10:00Z",
updated_at: "2026-07-02T05:10:00Z",
};

Expand All @@ -380,6 +382,7 @@ const webdavTask = {
source_type: "webdav",
source_email_id: String(sourceEmail.id),
related_thread_id: sourceEmail.thread_id,
created_at: "2026-07-02T05:20:00Z",
updated_at: "2026-07-02T05:20:00Z",
};

Expand Down Expand Up @@ -777,12 +780,13 @@ function routeJson(route, body, status = 200) {
});
}

async function installRoutes(page) {
export async function installRoutes(page) {
let emailSendCount = 0;
let savedAccountConfig = { ...accountConfig };
let savedLlmProviders = [{ ...llmProvider }];

await page.route("**/auth/session", (route) => routeJson(route, {
authenticated: true,
claims: {
userId: "smoke-user",
organizationId: "org-acme",
Expand Down Expand Up @@ -862,6 +866,7 @@ async function installRoutes(page) {
});
}
if (endpoint === "/api/webdav/folders") return routeJson(route, [projectFolder]);
if (endpoint === "/api/projects/candidates") return routeJson(route, { candidates: [] });
if (endpoint === "/api/webdav/accounts") return routeJson(route, [webdavAccount]);
if (endpoint === "/api/webdav/writeback-intent") {
return routeJson(route, {
Expand Down Expand Up @@ -1226,13 +1231,15 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) {
await projectContent.getByText("저장소 경계 확인됨", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await projectContent.getByText("WebDAV 폴더 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await projectContent.getByText("스레드 근거 연결됨", { exact: true }).first().waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("region", { name: "프로젝트 작업 목록" }).getByText("문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await page.getByRole("region", { name: "조회된 작업 목록" }).getByText("문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
const evidenceEditor = page.getByRole("region", { name: "프로젝트 근거 편집" });
await evidenceEditor.getByLabel("프로젝트 근거 메모", { exact: true }).fill("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다.");
await evidenceEditor.getByLabel("프로젝트 근거 메모", { exact: true }).fill("검토할 근거를 미저장 메모로 작성합니다.");
await evidenceEditor.getByLabel("연결 원본 변경", { exact: true }).selectOption({ label: "문서 근거" });
await evidenceEditor.getByRole("button", { name: "근거 저장", exact: true }).click();
await evidenceEditor.getByText("프로젝트 근거가 저장되었습니다: 문서 근거", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await evidenceEditor.getByText("20B 구매 심사용 WebDAV 경계와 이사회 승인 근거를 함께 저장합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
if (await evidenceEditor.getByRole("button", { name: "근거 저장", exact: true }).isEnabled()) {
throw new Error("Project evidence saving must remain unavailable without a persistence contract");
}
await evidenceEditor.getByText("메모 저장은 아직 지원하지 않습니다. 입력 내용은 이 화면에서만 유지되며 새로고침하면 사라집니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await evidenceEditor.getByText("검토할 근거를 미저장 메모로 작성합니다.", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
const connectedResources = page.getByRole("region", { name: "연결된 자원" });
await connectedResources.getByText("원본 종류", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
await connectedResources.locator("li").filter({ hasText: "원본 종류" }).getByText("3", { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
Expand All @@ -1246,8 +1253,8 @@ async function runCriticalInteractionSmoke(page, routeSpec, viewportSpec) {
evidence("projects:verify-document-source-attachment"),
evidence("projects:edit-evidence-note"),
evidence("projects:mutate-evidence-source"),
evidence("projects:save-evidence-note"),
evidence("projects:verify-evidence-save-state"),
evidence("projects:verify-evidence-save-unavailable"),
evidence("projects:verify-unsaved-evidence-preview"),
evidence("projects:verify-source-type-count"),
];
}
Expand Down
Loading
Loading