Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@ on:
branches: [main]
pull_request:

# Cancel superseded pull request runs; main verification runs finish normally.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
source-contracts:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand All @@ -20,6 +26,7 @@ jobs:
verify:
needs: source-contracts
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
Expand All @@ -33,27 +40,24 @@ jobs:
- run: pnpm run verify:canary:test
- run: pnpm run verify:canary:build
- run: pnpm run verify:canary:preview
# Includes the Storybook build and browser foundation checks.
- run: pnpm run verify:foundation
- run: pnpm run verify:release
# Package test scripts include their axe suites; avoid running them twice.
- run: pnpm run verify:solid:test
- run: pnpm run verify:solid:a11y
- run: pnpm run verify:solid:release
- run: pnpm run verify:table:test
- run: pnpm run verify:table:a11y
- run: pnpm run verify:table:release
- run: pnpm run verify:react:test
- run: pnpm run verify:react:a11y
- run: pnpm run verify:react:browser
- run: pnpm run verify:react:release
- run: pnpm run verify:table-react:test
- run: pnpm run verify:table-react:browser
- run: pnpm run verify:table-react:release
# i18n and SEO share these same core/publisher suites.
- run: pnpm run verify:site-kit:i18n
- run: pnpm run verify:site-kit:ui
- run: pnpm run verify:site-kit:seo
- run: pnpm run verify:site-kit:browser
- run: pnpm run verify:site-kit:release
# build all packages, then validate token JSON + docs/preview sync
- run: pnpm run verify
# stories must keep building against the current CSS/tokens
- run: pnpm exec storybook build
4 changes: 1 addition & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,12 @@ jobs:
- run: pnpm run verify:canary:preview
- run: pnpm run verify:foundation
- run: pnpm run verify:release
# Package test scripts already include their axe suites.
- run: pnpm run verify:solid:test
- run: pnpm run verify:solid:a11y
- run: pnpm run verify:solid:release
- run: pnpm run verify:table:test
- run: pnpm run verify:table:a11y
- run: pnpm run verify:table:release
- run: pnpm run verify:react:test
- run: pnpm run verify:react:a11y
- run: pnpm run verify:react:browser
- run: pnpm run verify:react:release
- run: pnpm run verify:table-react:test
Expand Down
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# dds 작업 지침

<!-- devslab-testing-policy:start -->
## 테스트 가치와 CI 비용

- 테스트는 실제 사용자 동작, 공개 API/호환성, 권한·보안, 데이터 무결성/손실 방지와 재현된 장애의 회귀 방지에 집중한다.
- 문서·문구·단순 스타일·설정 등 영향이 작은 변경에 테스트를 자동으로 추가하지 않는다. 새 파일보다 기존 테스트 보강을 우선한다.
- 구현을 그대로 복사한 예상값, 특정 변수명·CSS/JSX 문자열·파일 배치·전체 설정 스냅샷은 실제 계약이 아닌 한 새로 고정하지 않는다. 실제 접근성·보안·배포 계약을 소스 검사라는 이유만으로 없애지 않는다.
- 중복 검사는 보호 대상과 기존 대체 검사를 확인해 통합한다. 중요한 단위·통합·E2E 검사가 서로 다른 실패를 잡는다면 유지한다. 삭제/통합 이유와 남는 검증을 PR에 기록한다.
- 수정 중에는 실패한 테스트와 변경 영향 범위부터 실행한다. 실패를 확인하고 원인을 고치며, 성공할 때까지 근거 없이 반복 실행하거나 기준을 낮추지 않는다.
- 같은 커밋(SHA)의 CI 재시도는 `gh run rerun RUN_ID --failed --repo devslab-kr/REPOSITORY` 또는 특정 `--job JOB_ID`를 우선한다. 성공한 작업까지 다시 돌리는 전체 재시도는 필요한 근거가 있을 때만 한다.
- GitHub의 실패 작업 재시도는 그 작업의 준비 단계 전체와 필요한 후속 작업을 다시 실행할 수 있다. 로컬은 테스트 도구의 파일/케이스 필터로 실패 범위를 좁힌다.
- 코드를 수정해 SHA가 바뀌면 과거 SHA의 성공을 새 코드의 검증으로 간주하지 않는다. 수정 영향·의존 범위를 재검증하고 저장소의 필수 병합/릴리스 검증을 충족한다. 같은 SHA에서 이미 성공한 작업은 불필요하게 반복하지 않는다.
- PR의 오래된 실행은 concurrency로 취소하되 배포·릴리스 작업은 임의로 중단하지 않는다. 캐시·빌드 재사용으로 중복 비용을 줄이고 테스트 생략·실패 무시로 비용을 줄이지 않는다.
- 보고에는 검증한 SHA, 실행 명령/작업, 결과, 실행하지 않은 범위와 이유를 남긴다. 테스트 파일 수·커버리지 수치 자체를 목표로 삼지 않는다.
<!-- devslab-testing-policy:end -->
65 changes: 0 additions & 65 deletions tests/dds-solid-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,51 +33,6 @@ test("dds-solid is a public exact-version package with stable exports", async ()
]) assert.match(entry, new RegExp(`\\b${symbol}\\b`), `${symbol} must be exported`);
});

test("native controls preserve semantic elements and accessible names", async () => {
const controls = await read("packages/dds-solid/src/controls.tsx");
assert.match(controls, /<button/);
assert.match(controls, /type="checkbox"/);
assert.match(controls, /type="radio"/);
assert.match(controls, /useSwitch/);
assert.match(controls, /<select/);
assert.match(controls, /aria-invalid/);
assert.match(controls, /aria-describedby/);
assert.match(controls, /"aria-label": string/);
});

test("stateful primitives expose controlled and uncontrolled contracts", async () => {
const state = await read("packages/dds-solid/src/controllable.ts");
const dialog = await read("packages/dds-solid/src/dialog.tsx");
const tabs = await read("packages/dds-solid/src/tabs.tsx");
const toast = await read("packages/dds-solid/src/toast.tsx");
const tooltip = await read("packages/dds-solid/src/tooltip.tsx");
assert.match(state, /createControllableSignal/);
for (const source of [dialog, tabs, tooltip]) {
assert.match(source, /defaultOpen|defaultValue/);
assert.match(source, /onOpenChange|onValueChange/);
}
assert.match(toast, /createToaster/);
assert.match(toast, /onCleanup\(api.clear\)/);
assert.match(toast, /role=.*alert/);
});

test("Dialog and Tabs delegate keyboard and focus lifecycle to Ark through DDS contracts", async () => {
const dialog = await read("packages/dds-solid/src/dialog.tsx");
const tabs = await read("packages/dds-solid/src/tabs.tsx");
assert.match(dialog, /Escape/);
assert.match(dialog, /@ark-ui\/solid\/dialog/);
assert.match(dialog, /modal: !props.additionalFocusContainers/);
assert.match(dialog, /trapFocus: !props.additionalFocusContainers/);
assert.match(dialog, /preventScroll: true/);
assert.match(dialog, /@zag-js\/focus-trap/);
assert.match(dialog, /@zag-js\/aria-hidden/);
assert.match(dialog, /details.open/);
assert.match(tabs, /@ark-ui\/solid\/tabs/);
assert.match(tabs, /activationMode=\{props.activationMode \?\? "automatic"\}/);
assert.match(tabs, /lazyMount=\{false\} unmountOnExit=\{false\}/);
assert.match(tabs, /details.value/);
});

test("Icon and style adapters consume DDS framework-neutral contracts", async () => {
const icon = await read("packages/dds-solid/src/icon.tsx");
const styles = await read("packages/dds-solid/styles.css");
Expand All @@ -88,26 +43,6 @@ test("Icon and style adapters consume DDS framework-neutral contracts", async ()
assert.match(styles, /@devslab\/dds-css\/dds\.css/);
});

test("runtime suites cover every primitive, both state modes, focus lifecycle, a11y, and hydration", async () => {
const [primitives, a11y, ssr, release] = await Promise.all([
read("packages/dds-solid/src/__tests__/primitives.test.tsx"),
read("packages/dds-solid/src/__tests__/a11y.test.tsx"),
read("packages/dds-solid/src/__tests__/ssr.test.tsx"),
read("scripts/verify-solid-release.mjs"),
]);
for (const symbol of ["IconButton", "Field", "Select", "Radio", "Switch", "Tooltip", "ToastProvider", "Icon"]) {
assert.match(primitives, new RegExp(`\\b${symbol}\\b`), `${symbol} needs runtime coverage`);
}
assert.match(primitives, /controlled and uncontrolled/i);
assert.match(primitives, /focus trap cycle/i);
assert.match(primitives, /timer lifecycle/i);
for (const symbol of ["Dialog", "Tabs", "Tooltip", "ToastProvider", "Radio", "IconButton"]) assert.match(a11y, new RegExp(`\\b${symbol}\\b`));
for (const symbol of ["Dialog", "Tabs", "Tooltip", "ToastProvider", "Checkbox", "Radio", "Switch", "Select", "Field", "IconButton"]) assert.match(ssr, new RegExp(`\\b${symbol}\\b`));
assert.match(release, /renderToString/);
assert.match(release, /hydrate/);
assert.match(release, /@devslab\/dds-solid/);
});

test("Toast dismiss control has an injected localized accessible name without an English fallback", async () => {
const toast = await read("packages/dds-solid/src/toast.tsx");
assert.match(toast, /dismissLabel/);
Expand Down
27 changes: 10 additions & 17 deletions tests/dds-table-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,40 +22,33 @@ test("dds-table pins TanStack exactly and ships the worker condition", async ()
"An explicit browser condition must win over Node when testing hydration");
});

/** Every rule in a stylesheet as { selectors, body, comment } — the comment
* being the block comment written directly above the rule, if any. Media
* wrappers are unwrapped so their inner rules are listed too. */
/** Read stylesheet selectors and declarations, including rules inside media wrappers. */
function cssRules(text) {
const rules = [];
// The selector group has no braces to stop at, so it swallows the comment
// above the rule; the comment is recovered from it rather than matched
// separately.
const ruleRe = /([^{}]+)\{([^{}]*)\}/g;
for (const [, prelude, body] of text.replace(/@media[^{]+\{/g, "").matchAll(ruleRe)) {
const comments = [...prelude.matchAll(/\/\*[\s\S]*?\*\//g)].map(([comment]) => comment);
const selectors = prelude.replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((selector) => selector.trim()).filter(Boolean);
rules.push({ selectors, body, comment: comments.at(-1) ?? "" });
rules.push({ selectors, body });
}
return rules;
}

const ruleFor = (rules, selector) => rules.find((rule) => rule.selectors.includes(selector));

test("table.css carries the console rules, each with its reason", async () => {
test("table.css carries the console layout rules", async () => {
const rules = cssRules(await read("packages/dds-css/src/table.css"));
const expectations = [
[".dds-table code", /font-family:\s*var\(--dds-font-family-mono\)/, /character by character/],
[".dds-table__actions .dds-btn", /white-space:\s*nowrap/, /~53px/],
[".dds-table td:has(> .dds-table__actions)", /padding-block:\s*var\(--dds-space-4\)/, null],
[".dds-table td:has(.dds-btn--sm)", /padding-block:\s*var\(--dds-space-4\)/, /53px row/],
[".dds-table--dense .dds-btn--sm", /min-block-size:\s*var\(--dds-space-24\)/, /24px/],
[".dds-table-wrap--tall .dds-table", /border-collapse:\s*separate/, /sticky header from actually sticking/],
[".dds-table code", /font-family:\s*var\(--dds-font-family-mono\)/],
[".dds-table__actions .dds-btn", /white-space:\s*nowrap/],
[".dds-table td:has(> .dds-table__actions)", /padding-block:\s*var\(--dds-space-4\)/],
[".dds-table td:has(.dds-btn--sm)", /padding-block:\s*var\(--dds-space-4\)/],
[".dds-table--dense .dds-btn--sm", /min-block-size:\s*var\(--dds-space-24\)/],
[".dds-table-wrap--tall .dds-table", /border-collapse:\s*separate/],
];
for (const [selector, declaration, reason] of expectations) {
for (const [selector, declaration] of expectations) {
const rule = ruleFor(rules, selector);
assert.ok(rule, `table.css must define ${selector}`);
assert.match(rule.body, declaration, `${selector} must declare ${declaration}`);
if (reason) assert.match(rule.comment, reason, `${selector} must say why it exists`);
}
assert.match(ruleFor(rules, ".dds-table__actions .dds-btn").body, /min-block-size:\s*var\(--dds-space-32\)/);
assert.match(ruleFor(rules, ".dds-table--dense .dds-btn--sm").body, /padding-block:\s*0/);
Expand Down
40 changes: 36 additions & 4 deletions tests/foundation-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,10 @@ test("every publishable, non-foundation package's verify:<surface> scripts are w
const foundationTrio = new Set(["@devslab/dds-tokens", "@devslab/dds-css", "@devslab/dds-icons"]);
const packageDirs = await readdir(new URL("../packages/", import.meta.url));
const expectedSurfaces = new Set();
const packagesByName = new Map();
for (const dir of packageDirs) {
const manifest = await json(`packages/${dir}/package.json`);
packagesByName.set(manifest.name, { dir, manifest });
if (manifest.private || foundationTrio.has(manifest.name)) continue;
expectedSurfaces.add(dir.replace(/^dds-/, ""));
}
Expand All @@ -147,15 +149,45 @@ test("every publishable, non-foundation package's verify:<surface> scripts are w
"every non-foundation publishable package must define its own verify:<surface>:* scripts, and vice versa",
);

const invokedScripts = (workflow) => [...workflow.matchAll(/^\s*-\s*run:\s*pnpm run ([\w:-]+)\s*$/gm)]
.map(([, script]) => script);
// These selected-file commands remain available locally. Each can be
// consolidated only while its package test executes the same Vitest suite.
const includedChecks = new Map();
for (const surface of ["solid", "table", "react"]) {
const pkg = packagesByName.get(`@devslab/dds-${surface}`);
const file = "src/__tests__/a11y.test.tsx";
await read(`packages/${pkg.dir}/${file}`);
const suite = surface === "react" ? "vitest run" : "vitest run --config vitest.config.ts";
assert.equal(root.scripts[`verify:${surface}:a11y`], `pnpm --filter ${pkg.manifest.name} run test:a11y`);
assert.equal(pkg.manifest.scripts["test:a11y"], `${suite} ${file}`);
assert.ok(pkg.manifest.scripts.test.split(" && ").includes(suite));
const config = await read(`packages/${pkg.dir}/vitest.config.ts`);
const patterns = (key) => [...(config.match(new RegExp(`\\b${key}:\\s*\\[([^\\]]*)\\]`))?.[1] ?? "")
.matchAll(/["']([^"']+)["']/g)].map(([, pattern]) => pattern);
assert.ok(patterns("include").some((pattern) => [file, "src/__tests__/*.test.tsx"].includes(pattern)));
assert.ok(patterns("exclude").every((pattern) => pattern === "src/__tests__/ssr.test.tsx"),
"consolidated a11y checks must not be excluded from the package suite");
assert.ok(root.scripts[`verify:${surface}:test`].split(" && ")
.includes(`pnpm --filter ${pkg.manifest.name} run test`));
includedChecks.set(`verify:${surface}:a11y`, `verify:${surface}:test`);
}
const covered = (script, workflow) => {
const invoked = invokedScripts(workflow);
return invoked.includes(script)
|| invoked.includes(includedChecks.get(script))
// Identical commands (currently site-kit i18n/SEO) protect the same checks.
|| invoked.some((name) => root.scripts[name] === root.scripts[script]);
};

for (const [surface, suffixes] of surfaceScripts) {
assert.ok(suffixes.has("release"), `verify:${surface}:release is required`);
for (const suffix of suffixes) {
const script = `verify:${surface}:${suffix}`;
const [workflow, workflowName] = suffix === "release" ? [release, "release.yml"] : [ci, "ci.yml"];
assert.match(
workflow,
new RegExp(`pnpm run ${script.replaceAll(":", "\\:")}(?:\\s|$)`, "m"),
`${script} must run in ${workflowName}`,
assert.ok(
covered(script, workflow),
`${script} must run directly or through an equivalent check in ${workflowName}`,
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion tests/site-kit-contracts.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ test("Worker and browser fixtures consume adapters and prove hydration plus Arab
assert.match(browser, /toBeFocused/);
assert.match(browser, /aria-expanded/);
assert.equal(typeof root.scripts?.["verify:site-kit:browser"], "string");
for (const gate of ["verify:source:stage3-4", "verify:solid:test", "verify:solid:a11y", "verify:solid:release", "verify:site-kit:ui", "verify:site-kit:browser", "verify:site-kit:release"]) {
// foundation-contracts verifies that solid:test includes the axe suite.
for (const gate of ["verify:source:stage3-4", "verify:solid:test", "verify:solid:release", "verify:site-kit:ui", "verify:site-kit:browser", "verify:site-kit:release"]) {
assert.match(workflow, new RegExp(gate.replaceAll(":", "\\:")), `${gate} must gate CI`);
}
});
Expand Down
Loading