Skip to content

fix(public-status): prevent duplicate group slugs#1102

Merged
ding113 merged 6 commits intodevfrom
fix/public-status-group-slugs-20260425
Apr 25, 2026
Merged

fix(public-status): prevent duplicate group slugs#1102
ding113 merged 6 commits intodevfrom
fix/public-status-group-slugs-20260425

Conversation

@ding113
Copy link
Copy Markdown
Owner

@ding113 ding113 commented Apr 25, 2026

Summary

  • Generate stable unique public status group slugs for non-English and normalized-colliding provider group names.
  • Keep Redis public-status projection publishing safe when legacy stored slugs collide.
  • Block duplicate enabled group slugs before saving settings, then expand, scroll, focus, and highlight the conflicting slug inputs.

Problem

Provider groups such as cc特价 and cc逆向 can normalize to the same ASCII slug (cc). That made public status initialization fragile and could trigger DuplicatePublicStatusGroupSlugError during Redis projection publishing.

Solution

  • Add stable suffix-based slug generation for non-English or otherwise colliding group names.
  • Add shared normalization helpers so loader, publisher, backend validation, and UI preflight use the same semantics.
  • Use a suffix strategy for Redis projection publishing so legacy duplicate stored slugs do not break public status output.
  • Pre-collect explicit stored slugs in the settings loader before generating default slugs, preventing later explicit slugs from colliding with earlier generated ones.
  • Add save-time UI duplicate slug detection with i18n errors, collapsed-section expansion, scroll/focus, and invalid input highlighting.

Tests

  • bunx vitest run tests/unit/public-status/public-status-config.test.ts tests/unit/settings/status-page/public-status-settings-form.test.tsx tests/unit/settings/status-page/status-page-loader.test.tsx — 3 files / 18 tests passed
  • bun run build — passed; existing Turbopack Edge-runtime warnings for node:async_hooks / node:net remain warnings
  • bun run lint — passed; existing Biome schema-version info and two unsafe suggestions in dashboard tests
  • bun run lint:fix — passed; no fixes applied
  • bun run typecheck — passed
  • bun run test — 545 files passed, 5073 tests passed, 2 files / 13 tests skipped

UI Screenshot

Duplicate slug validation

Greptile Summary

This PR implements a three-layer fix for duplicate public status group slugs: FNV-1a-inspired hash suffixes for non-ASCII group names in slugifyPublicGroup, a duplicateSlugStrategy: "suffix" option in collectEnabledPublicStatusGroups to de-duplicate legacy Redis projections without throwing, and a save-time findDuplicateSlugError preflight in the settings form with expand/highlight/scroll/focus UX. The loader.ts correctly uses a two-pass approach (collect explicit slugs first, then auto-generate) to avoid initial-state conflicts.

Confidence Score: 5/5

Safe to merge; all findings are P2 edge cases that do not affect correctness under normal usage.

No P0 or P1 issues found. The two P2 findings are: (1) only the first conflicting slug pair is highlighted per save attempt when multiple independent conflicts exist, and (2) the single-pass suffix resolution in collectEnabledPublicStatusGroups could theoretically displace a later group's explicit custom slug if it happens to match an auto-generated hash — an extremely unlikely scenario confined to legacy Redis projection. The implementation is well-structured with comprehensive test coverage across all three layers.

src/lib/public-status/config.ts (suffix collision edge case) and src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx (single-conflict-per-save UX).

Important Files Changed

Filename Overview
src/lib/public-status/config.ts Adds FNV-1a-inspired hash suffix generation for non-ASCII slugs, createUniquePublicGroupSlug / createCollisionPublicGroupSlug helpers, and a duplicateSlugStrategy option for collectEnabledPublicStatusGroups. One edge-case: auto-resolved suffix slugs can displace a later group's explicit custom slug in the suffix path.
src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx Adds findDuplicateSlugError preflight on save, slug-input ref map, group expansion on conflict, aria-invalid highlighting, scroll/focus behavior, and inline error text. Only the first conflicting slug pair is surfaced per save attempt.
src/app/[locale]/settings/status-page/loader.ts Refactored to a correct two-pass approach: first pass collects all normalized explicit slugs, second pass calls createUniquePublicGroupSlug for groups without stored slugs, preventing auto-generated slugs from colliding with any later explicit custom slug.
src/lib/public-status/config-publisher.ts Passes duplicateSlugStrategy: suffix to tolerate legacy colliding slugs during Redis projection; minimal, targeted change.
tests/unit/public-status/public-status-config.test.ts Adds tests for non-English slug uniqueness and the default duplicate-throw behavior; comprehensive coverage of new slug logic.
tests/unit/public-status/config-publisher.test.ts Adds regression test for Redis projection with legacy colliding slugs, verifying the first group keeps the original slug and the second gets a cc-{hash} suffix.
tests/unit/settings/status-page/public-status-settings-form.test.tsx Adds three form tests: save blocking, group expansion before focus, and multi-group conflict highlighting; good coverage of UI interaction paths.
tests/unit/settings/status-page/status-page-loader.test.tsx Adds tests for non-English unique default slugs and a two-pass collision avoidance scenario; validates the loader's explicit-first design.

Sequence Diagram

sequenceDiagram
    participant User
    participant Form as PublicStatusSettingsForm
    participant Config as config.ts
    participant Publisher as config-publisher.ts
    participant Redis

    User->>Form: Click Save
    Form->>Config: findDuplicateSlugError(groups)
    alt Duplicate found
        Config-->>Form: { slug, groupNames }
        Form->>Form: expand conflicting groups
        Form->>Form: highlight slug inputs (aria-invalid)
        Form->>User: toast.error + scroll/focus first input
    else No duplicate
        Form->>Form: build SavePublicStatusSettingsInput payload
        Form->>Publisher: savePublicStatusSettings(payload)
        Publisher->>Config: collectEnabledPublicStatusGroups(..., { duplicateSlugStrategy: suffix })
        Config->>Config: normalizePublicGroupSlug per group
        alt Legacy slug collision detected
            Config->>Config: createCollisionPublicGroupSlug (FNV-1a suffix loop)
        end
        Config-->>Publisher: EnabledPublicStatusGroup[]
        Publisher->>Redis: publishCurrentPublicStatusConfigProjection
        Redis-->>Publisher: written
        Publisher-->>Form: { ok: true }
        Form->>User: toast.success
    end
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx
Line: 106-131

Comment:
**Duplicate-slug validation surfaces only one conflict set per save**

`findDuplicateSlugError` iterates the `groupNamesBySlug` map and returns on the first entry whose `groupNames.length > 1`. If two independent slug conflicts exist simultaneously (e.g., Groups A & B share `"foo"` and Groups C & D share `"bar"`), only the A/B pair is highlighted. Groups C and D remain un-highlighted, and the user must fix the first conflict, save again (triggering a second block), and fix the second conflict before the save succeeds.

Consider collecting all conflicting entries in a single pass and returning a `Map<slug, groupNames[]>` (or `DuplicateSlugErrorState[]`) so all conflicts are surfaced at once.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/lib/public-status/config.ts
Line: 380-388

Comment:
**Auto-resolved suffix slug can displace a later group's explicit custom slug**

`collectEnabledPublicStatusGroups` processes groups in order. When Group B's slug collides with Group A and is auto-resolved to (e.g.) `"cc-a1b2c3"`, that resolved slug is immediately written to `seenGroupNamesBySlug`. If a later Group C happens to carry the explicit custom slug `"cc-a1b2c3"`, it is treated as a collision with B and auto-resolved to yet another slug — silently overriding C's intentional choice.

The probability is low (the user would have to set a slug matching the exact 6-char base-36 hash), and the impact is limited to the `"suffix"` strategy used only in `config-publisher.ts`. Nonetheless, a two-pass approach (collect all explicit slugs into `usedSlugs` first, then resolve collisions) would close this gap analogously to how `loader.ts` already handles it.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(status-page): align duplicate slug v..." | Re-trigger Greptile

ding113 and others added 5 commits April 25, 2026 06:27
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 25, 2026

📝 Walkthrough

Walkthrough

为公开状态页面引入重复 public-group slug 检测与处理:新增多语言提示、表单提交时校验并标记冲突、加载器预先追踪已用 slug、以及在配置工具中新增确定性唯一 slug 生成与冲突解决策略,同时增加相应单元测试覆盖。

Changes

Cohort / File(s) Summary
国际化消息
messages/en/settings/statusPage.json, messages/ja/settings/statusPage.json, messages/ru/settings/statusPage.json, messages/zh-CN/settings/statusPage.json, messages/zh-TW/settings/statusPage.json
新增 form.duplicateSlug 多语言文案,用于在多个公开分组使用相同 slug 时向用户提示并阻止保存。
公开状态设置表单(UI/交互)
src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx
在保存流程中加入 findDuplicateSlugError() 校验;冲突时展开相关分组、设置 duplicateSlugError、展示 toast、将冲突 slug 输入标记为无效并滚动/聚焦第一个冲突项;移除旧本地 slugify 辅助。
状态页加载器
src/app/[locale]/settings/status-page/loader.ts
解析 provider groups 为 parsedGroups,收集 usedDefaultSlugs,构建 initialGroups 时优先使用显式配置 slug,否则通过 createUniquePublicGroupSlug 基于已有已用集合生成唯一 slug(不再回退为空字符串)。
公开状态配置工具(核心逻辑)
src/lib/public-status/config.ts
新增并导出 createUniquePublicGroupSlug();增强 slugifyPublicGroup() 支持非 ASCII:生成 ASCII 基础 slug,遇非 ASCII 时附加确定性哈希后缀并强制最大长度;collectEnabledPublicStatusGroups() 签名新增 options(含 duplicateSlugStrategy),可选择以后缀策略解决规范化冲突并忽略无可发布 models 的已配置组。
配置发布器
src/lib/public-status/config-publisher.ts
调用 collectEnabledPublicStatusGroups 时传入 { duplicateSlugStrategy: "suffix" },启用后缀策略以自动解决重复 slug。
单元测试
tests/unit/public-status/config-publisher.test.ts, tests/unit/public-status/public-status-config.test.ts, tests/unit/settings/status-page/public-status-settings-form.test.tsx, tests/unit/settings/status-page/status-page-loader.test.tsx
新增/扩展测试:验证遗留 slug 碰撞的生成策略、非英文名称生成唯一 slug、不允许规范化后重复的 enabled 组、表单重复 slug 校验与展开/聚焦/aria-invalid 行为、以及加载器的唯一 slug 生成与避碰逻辑。

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR title clearly summarizes the main change: preventing duplicate public status group slugs through generation, publishing, and UI validation fixes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed PR描述详细说明了问题、解决方案和实现细节,与变更集的所有主要部分(拆分为三层修复)直接相关。

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/public-status-group-slugs-20260425

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added bug Something isn't working area:UI area:i18n labels Apr 25, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This PR introduces robust slug management for public status pages, including support for non-ASCII characters via stable hashing and a validation mechanism to prevent duplicate slugs. Key changes include the implementation of a collision-resistant slugification utility, UI updates to highlight and navigate to conflicting inputs in the settings form, and logic to ensure unique default slugs are generated during data loading. Feedback focuses on improving the comprehensive detection of all slug collisions in the UI, adopting a two-pass approach in the loader to prevent potential collisions between generated and custom slugs, and refactoring duplicated logic in the slug generation utilities.

Comment on lines +68 to 95
function findDuplicateSlugError(
groups: PublicStatusSettingsFormGroup[]
): DuplicateSlugErrorState | null {
const seenGroupNameBySlug = new Map<string, string>();

for (const group of groups) {
if (!group.enabled || normalizePublicStatusModels(group.publicModels).length === 0) {
continue;
}

const normalizedSlug = slugifyPublicGroup(group.publicGroupSlug.trim() || group.groupName);
if (!normalizedSlug) {
continue;
}

const existingGroupName = seenGroupNameBySlug.get(normalizedSlug);
if (existingGroupName) {
return {
slug: normalizedSlug,
groupNames: [existingGroupName, group.groupName],
};
}

seenGroupNameBySlug.set(normalizedSlug, group.groupName);
}

return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The findDuplicateSlugError function returns as soon as it identifies the first collision between two groups. If three or more groups share the same slug, only the first two will be highlighted in the UI.

Consider collecting all group names that share a conflicting slug to provide a more comprehensive error state and better UX.

Comment on lines +17 to +32
const usedDefaultSlugs = new Set<string>();

return {
initialWindowHours: settings.publicStatusWindowHours,
initialAggregationIntervalMinutes: settings.publicStatusAggregationIntervalMinutes,
initialGroups: groups.map((group) => {
const parsed = parsePublicStatusDescription(group.description);
const publicGroupSlug =
parsed.publicStatus?.publicGroupSlug ??
createUniquePublicGroupSlug(group.name, usedDefaultSlugs);
if (parsed.publicStatus?.publicGroupSlug) {
const normalizedConfiguredSlug = slugifyPublicGroup(parsed.publicStatus.publicGroupSlug);
if (normalizedConfiguredSlug) {
usedDefaultSlugs.add(normalizedConfiguredSlug);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current single-pass approach for generating default slugs can lead to collisions if a group appearing later in the list has an explicit custom slug that matches a generated slug for an earlier group.

Collecting all explicit custom slugs in a first pass ensures that generated defaults for other groups are truly unique and avoids unnecessary validation errors in the initial form state.

  const usedDefaultSlugs = new Set<string>();
  for (const group of groups) {
    const parsed = parsePublicStatusDescription(group.description);
    const slug = parsed.publicStatus?.publicGroupSlug;
    if (slug) {
      const normalized = slugifyPublicGroup(slug);
      if (normalized) usedDefaultSlugs.add(normalized);
    }
  }

  return {
    initialWindowHours: settings.publicStatusWindowHours,
    initialAggregationIntervalMinutes: settings.publicStatusAggregationIntervalMinutes,
    initialGroups: groups.map((group) => {
      const parsed = parsePublicStatusDescription(group.description);
      const publicGroupSlug =
        parsed.publicStatus?.publicGroupSlug ??
        createUniquePublicGroupSlug(group.name, usedDefaultSlugs);

Comment thread src/lib/public-status/config.ts Outdated
Comment on lines +205 to +225
export function createUniquePublicGroupSlug(groupName: string, usedSlugs: Set<string>): string {
const baseSlug = normalizePublicGroupSlug(groupName);
if (!usedSlugs.has(baseSlug)) {
usedSlugs.add(baseSlug);
return baseSlug;
}

let counter = 1;
let candidate = baseSlug;
while (usedSlugs.has(candidate)) {
const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
candidate = appendStablePublicGroupSlugSuffix(
baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
createStablePublicGroupSlugSuffix(suffixSource)
);
counter += 1;
}

usedSlugs.add(candidate);
return candidate;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for resolving slug collisions is duplicated between createUniquePublicGroupSlug and createCollisionPublicGroupSlug. Refactoring createUniquePublicGroupSlug to use the helper function improves maintainability and reduces code duplication.

export function createUniquePublicGroupSlug(groupName: string, usedSlugs: Set<string>): string {
  const baseSlug = normalizePublicGroupSlug(groupName);
  const uniqueSlug = createCollisionPublicGroupSlug(baseSlug, groupName, usedSlugs);
  usedSlugs.add(uniqueSlug);
  return uniqueSlug;
}

@github-actions github-actions Bot added the size/L Large PR (< 1000 lines) label Apr 25, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

Comment on lines +22 to +32
initialGroups: groups.map((group) => {
const parsed = parsePublicStatusDescription(group.description);
const publicGroupSlug =
parsed.publicStatus?.publicGroupSlug ??
createUniquePublicGroupSlug(group.name, usedDefaultSlugs);
if (parsed.publicStatus?.publicGroupSlug) {
const normalizedConfiguredSlug = slugifyPublicGroup(parsed.publicStatus.publicGroupSlug);
if (normalizedConfiguredSlug) {
usedDefaultSlugs.add(normalizedConfiguredSlug);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Single-pass ordering can produce initial slug collisions

Because explicit slugs are added to usedDefaultSlugs only after their group is processed, a group without a stored slug that is iterated first can grab the same base slug as an explicit slug on a later group. For example, if Group A has no slug and its name normalizes to "cc", it receives "cc" and usedDefaultSlugs becomes {"cc"}. Group B with stored slug "cc" then also returns "cc" as publicGroupSlug, leaving the form in an already-conflicting initial state that blocks saving.

A two-pass approach would prevent this: collect all normalized explicit slugs into usedDefaultSlugs first, then run the auto-generation pass.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/status-page/loader.ts
Line: 22-32

Comment:
**Single-pass ordering can produce initial slug collisions**

Because explicit slugs are added to `usedDefaultSlugs` only after their group is processed, a group without a stored slug that is iterated first can grab the same base slug as an explicit slug on a later group. For example, if Group A has no slug and its name normalizes to `"cc"`, it receives `"cc"` and `usedDefaultSlugs` becomes `{"cc"}`. Group B with stored slug `"cc"` then also returns `"cc"` as `publicGroupSlug`, leaving the form in an already-conflicting initial state that blocks saving.

A two-pass approach would prevent this: collect all normalized explicit slugs into `usedDefaultSlugs` first, then run the auto-generation pass.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +227 to +244
function createCollisionPublicGroupSlug(
baseSlug: string,
groupName: string,
usedSlugs: Set<string>
): string {
let counter = 1;
let candidate = baseSlug;
while (usedSlugs.has(candidate)) {
const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
candidate = appendStablePublicGroupSlugSuffix(
baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
createStablePublicGroupSlugSuffix(suffixSource)
);
counter += 1;
}

return candidate;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 createCollisionPublicGroupSlug duplicates createUniquePublicGroupSlug's loop body

The while-loop in createCollisionPublicGroupSlug is byte-for-byte identical to the one inside createUniquePublicGroupSlug. The only difference is that createUniquePublicGroupSlug also handles the no-collision fast path and calls usedSlugs.add. createCollisionPublicGroupSlug could delegate to a shared private helper, eliminating the duplicate.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/public-status/config.ts
Line: 227-244

Comment:
**`createCollisionPublicGroupSlug` duplicates `createUniquePublicGroupSlug`'s loop body**

The while-loop in `createCollisionPublicGroupSlug` is byte-for-byte identical to the one inside `createUniquePublicGroupSlug`. The only difference is that `createUniquePublicGroupSlug` also handles the no-collision fast path and calls `usedSlugs.add`. `createCollisionPublicGroupSlug` could delegate to a shared private helper, eliminating the duplicate.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 275c2fb19d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +24 to +28
const publicGroupSlug =
parsed.publicStatus?.publicGroupSlug ??
createUniquePublicGroupSlug(group.name, usedDefaultSlugs);
if (parsed.publicStatus?.publicGroupSlug) {
const normalizedConfiguredSlug = slugifyPublicGroup(parsed.publicStatus.publicGroupSlug);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve configured slugs before assigning default slugs

This loop generates a default slug before adding the current group's configured slug into usedDefaultSlugs, so ordering can still produce duplicates: an earlier no-slug group may get alpha, and a later group with stored slug alpha is only registered afterward. That leaves duplicate normalized slugs in the loaded form and defeats the “unique default slug” hydration goal for mixed legacy data. Seed usedDefaultSlugs with configured slugs first (or reserve the current configured slug before calling createUniquePublicGroupSlug).

Useful? React with 👍 / 👎.

Comment on lines +78 to +80
const normalizedSlug = slugifyPublicGroup(group.publicGroupSlug.trim() || group.groupName);
if (!normalizedSlug) {
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize duplicate check with backend fallback behavior

The client-side duplicate preflight drops groups when slugifyPublicGroup(...) returns an empty string, but server-side normalization falls back to groupName when a provided slug normalizes empty. In cases like punctuation-only slugs ("!!!") across groups whose names normalize to the same slug, the UI will allow submit while collectEnabledPublicStatusGroups still throws on save. Use the same fallback normalization as the backend instead of continue on empty normalizedSlug.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
tests/unit/public-status/config-publisher.test.ts (1)

280-328: 冲突 slug 容错测试 LGTM;可选:补充内部快照断言。

此处仅断言 public snapshot 中两个分组的 slug 映射,建议同时对 mockPublishInternalPublicStatusConfigSnapshot 做断言,确保被加后缀的 cc逆向 在内部快照里仍然以 sourceGroupName: "cc逆向" 与生成的新 slug 正确关联,避免后续指标聚合错位。

♻️ 可选追加断言示例
     expect(result.written).toBe(true);
+    expect(mockPublishInternalPublicStatusConfigSnapshot).toHaveBeenCalledWith(
+      expect.objectContaining({
+        snapshot: expect.objectContaining({
+          groups: expect.arrayContaining([
+            expect.objectContaining({ sourceGroupName: "cc特价", slug: "cc" }),
+            expect.objectContaining({
+              sourceGroupName: "cc逆向",
+              slug: expect.stringMatching(/^cc-[a-z0-9]{6}$/),
+            }),
+          ]),
+        }),
+      })
+    );
     expect(mockPublishPublicStatusConfigSnapshot).toHaveBeenCalledWith(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/public-status/config-publisher.test.ts` around lines 280 - 328,
Add an assertion that verifies mockPublishInternalPublicStatusConfigSnapshot is
called with an internal snapshot mapping the original sourceGroupName to the
generated slug: after calling publishCurrentPublicStatusConfigProjection, assert
mockPublishInternalPublicStatusConfigSnapshot was invoked with an
objectContaining({ snapshot: objectContaining({ groups: arrayContaining([
objectContaining({ sourceGroupName: "cc逆向", slug:
expect.stringMatching(/^cc-[a-z0-9]{6}$/) }) ]) }) }) so the
internally-published snapshot preserves the original group name and the same
generated slug used in mockPublishPublicStatusConfigSnapshot.
src/app/[locale]/settings/status-page/loader.ts (1)

23-32: 可选:抽取本地变量减少对 parsed.publicStatus?.publicGroupSlug 的重复读取

逻辑本身正确(已配置的 slug 走原值并把归一化结果计入 usedDefaultSlugs,未配置则用 createUniquePublicGroupSlug 生成),但 parsed.publicStatus?.publicGroupSlug 被读取了三次,且 ??(nullish)与 if (...)(truthy)两个分支的判定语义存在细微不一致(实践中 sanitizeString 已保证非空字符串,所以无 bug)。可抽个本地常量让分支结构更清晰:

♻️ 建议改写
-      const publicGroupSlug =
-        parsed.publicStatus?.publicGroupSlug ??
-        createUniquePublicGroupSlug(group.name, usedDefaultSlugs);
-      if (parsed.publicStatus?.publicGroupSlug) {
-        const normalizedConfiguredSlug = slugifyPublicGroup(parsed.publicStatus.publicGroupSlug);
-        if (normalizedConfiguredSlug) {
-          usedDefaultSlugs.add(normalizedConfiguredSlug);
-        }
-      }
+      const configuredSlug = parsed.publicStatus?.publicGroupSlug;
+      let publicGroupSlug: string;
+      if (configuredSlug) {
+        publicGroupSlug = configuredSlug;
+        const normalizedConfiguredSlug = slugifyPublicGroup(configuredSlug);
+        if (normalizedConfiguredSlug) {
+          usedDefaultSlugs.add(normalizedConfiguredSlug);
+        }
+      } else {
+        publicGroupSlug = createUniquePublicGroupSlug(group.name, usedDefaultSlugs);
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/settings/status-page/loader.ts around lines 23 - 32,
Extract parsed.publicStatus?.publicGroupSlug into a local constant (e.g., const
configuredSlug = parsed.publicStatus?.publicGroupSlug) and use that constant for
the nullish-coalescing when computing publicGroupSlug, and for the conditional
normalization block that calls slugifyPublicGroup; keep
createUniquePublicGroupSlug(group.name, usedDefaultSlugs) and
usedDefaultSlugs.add(...) semantics the same and reference those
functions/variables (parsed, configuredSlug, publicGroupSlug,
createUniquePublicGroupSlug, usedDefaultSlugs, slugifyPublicGroup) so the
conditional is consistent and the repeated property access is eliminated.
tests/unit/settings/status-page/public-status-settings-form.test.tsx (1)

379-440: 测试清理建议:将 prototype 修改与 spy 还原放入 try/finally 或 afterEach

Object.defineProperty(HTMLElement.prototype, "scrollIntoView", …) 直接修改了原型,但没有对应的还原逻辑;requestAnimationFrame.mockRestore() 也只在断言全部通过时才会执行。一旦中间任意一个 expect 抛出,原型上的 mock 与 spy 都会泄漏到后续用例(哪怕文件内的下一个用例覆盖了 scrollIntoView,也会掩盖失败现场)。建议用 afterEachtry/finally 保证清理。

♻️ 建议改写
   it("blocks submit and highlights slug inputs when enabled groups share the same slug", async () => {
     const scrollIntoView = vi.fn();
+    const originalScrollIntoView = Object.getOwnPropertyDescriptor(
+      HTMLElement.prototype,
+      "scrollIntoView"
+    );
     Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
       configurable: true,
       value: scrollIntoView,
     });
     const requestAnimationFrame = vi
       .spyOn(window, "requestAnimationFrame")
       .mockImplementation((callback) => {
         callback(0);
         return 0;
       });
+    try {
       // ... existing test body ...
+    } finally {
+      requestAnimationFrame.mockRestore();
+      if (originalScrollIntoView) {
+        Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScrollIntoView);
+      } else {
+        delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView;
+      }
+    }
   });

同样适用于 442-532 行的第二个用例。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/status-page/public-status-settings-form.test.tsx` around
lines 379 - 440, The test mutates HTMLElement.prototype.scrollIntoView and
installs a spy on window.requestAnimationFrame without guaranteed cleanup; wrap
the prototypal override and the requestAnimationFrame spy in a try/finally (or
move the setup to a beforeEach and the cleanup to an afterEach) so that
HTMLElement.prototype.scrollIntoView is restored and
requestAnimationFrame.mockRestore() is always called even if assertions fail;
refer to the symbols HTMLElement.prototype.scrollIntoView, requestAnimationFrame
(vi.spyOn(window, "requestAnimationFrame")), and
requestAnimationFrame.mockRestore() to locate and implement the safe teardown.
src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx (1)

387-407: onChange 全局清空 duplicateSlugError 会同时取消另一个冲突输入框的高亮

在某个分组的 slug 输入框里输入字符时,setDuplicateSlugError(null) 会一次性把所有冲突分组上的 aria-invalid、destructive 边框和内联错误提示一起清掉,而此时另一个真正还冲突的分组其实并没有被修改。用户可能因此误以为问题已经解决,再次点击保存又会被提示同样的错误。

可以考虑只在“当前分组”的 slug 真正被修改后清掉错误,或者在 onChange 里重新跑一次 findDuplicateSlugError 决定是否清除。当前实现作为简化版可接受,所以仅作为可选优化提出。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/app/`[locale]/settings/status-page/_components/public-status-settings-form.tsx
around lines 387 - 407, 当前 onChange 处理器在任何字符输入时直接调用
setDuplicateSlugError(null),会清掉所有分组的冲突高亮与提示;请改为只在“当前分组”真实不再冲突时清除,方法是在 onChange
里先用 updateGroup 构造包含新 publicGroupSlug 的临时分组列表,然后调用 findDuplicateSlugError(…) 或检查
duplicateSlugError?.groupId 与当前 group.id 来决定是否调用 setDuplicateSlugError(null);定位到
onChange 中对 setDuplicateSlugError 和 updateGroup 的调用并用上述条件逻辑替换直接清空的行为。
src/lib/public-status/config.ts (1)

205-244: createUniquePublicGroupSlugcreateCollisionPublicGroupSlug 存在明显的循环体重复

两者从 let counter = 1counter += 1 这段后缀分配循环逻辑完全一致,仅在“是否提前命中 baseSlug 直接返回”和“是否把结果写回 usedSlugs”这两点上不同。建议抽出一个共享的内部 helper(例如 resolveSlugWithStableSuffix),让两个导出/内部函数共用,减少未来调整后缀策略时的维护成本与不一致风险。

♻️ 建议改写
+function resolveSlugWithStableSuffix(
+  baseSlug: string,
+  groupName: string,
+  usedSlugs: Set<string>
+): string {
+  let counter = 1;
+  let candidate = baseSlug;
+  while (usedSlugs.has(candidate)) {
+    const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
+    candidate = appendStablePublicGroupSlugSuffix(
+      baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
+      createStablePublicGroupSlugSuffix(suffixSource)
+    );
+    counter += 1;
+  }
+  return candidate;
+}
+
 export function createUniquePublicGroupSlug(groupName: string, usedSlugs: Set<string>): string {
   const baseSlug = normalizePublicGroupSlug(groupName);
-  if (!usedSlugs.has(baseSlug)) {
-    usedSlugs.add(baseSlug);
-    return baseSlug;
-  }
-
-  let counter = 1;
-  let candidate = baseSlug;
-  while (usedSlugs.has(candidate)) {
-    const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
-    candidate = appendStablePublicGroupSlugSuffix(
-      baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
-      createStablePublicGroupSlugSuffix(suffixSource)
-    );
-    counter += 1;
-  }
-
+  const candidate = resolveSlugWithStableSuffix(baseSlug, groupName, usedSlugs);
   usedSlugs.add(candidate);
   return candidate;
 }

-function createCollisionPublicGroupSlug(
-  baseSlug: string,
-  groupName: string,
-  usedSlugs: Set<string>
-): string {
-  let counter = 1;
-  let candidate = baseSlug;
-  while (usedSlugs.has(candidate)) {
-    const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
-    candidate = appendStablePublicGroupSlugSuffix(
-      baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
-      createStablePublicGroupSlugSuffix(suffixSource)
-    );
-    counter += 1;
-  }
-
-  return candidate;
-}
+const createCollisionPublicGroupSlug = resolveSlugWithStableSuffix;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/public-status/config.ts` around lines 205 - 244, The loop that builds
a non-colliding slug is duplicated in createUniquePublicGroupSlug and
createCollisionPublicGroupSlug; extract a shared helper (e.g.,
resolveSlugWithStableSuffix(baseSlug: string, groupName: string, usedSlugs:
Set<string>) => string) that encapsulates the while loop using
appendStablePublicGroupSlugSuffix, createStablePublicGroupSlugSuffix and
PUBLIC_STATUS_SLUG_FALLBACK_PREFIX, then have createCollisionPublicGroupSlug
call that helper and have createUniquePublicGroupSlug use it for the collision
case (retaining the early-return when baseSlug is unused and ensuring
createUniquePublicGroupSlug still adds the final candidate to usedSlugs before
returning).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@src/app/`[locale]/settings/status-page/_components/public-status-settings-form.tsx:
- Around line 387-407: 当前 onChange 处理器在任何字符输入时直接调用
setDuplicateSlugError(null),会清掉所有分组的冲突高亮与提示;请改为只在“当前分组”真实不再冲突时清除,方法是在 onChange
里先用 updateGroup 构造包含新 publicGroupSlug 的临时分组列表,然后调用 findDuplicateSlugError(…) 或检查
duplicateSlugError?.groupId 与当前 group.id 来决定是否调用 setDuplicateSlugError(null);定位到
onChange 中对 setDuplicateSlugError 和 updateGroup 的调用并用上述条件逻辑替换直接清空的行为。

In `@src/app/`[locale]/settings/status-page/loader.ts:
- Around line 23-32: Extract parsed.publicStatus?.publicGroupSlug into a local
constant (e.g., const configuredSlug = parsed.publicStatus?.publicGroupSlug) and
use that constant for the nullish-coalescing when computing publicGroupSlug, and
for the conditional normalization block that calls slugifyPublicGroup; keep
createUniquePublicGroupSlug(group.name, usedDefaultSlugs) and
usedDefaultSlugs.add(...) semantics the same and reference those
functions/variables (parsed, configuredSlug, publicGroupSlug,
createUniquePublicGroupSlug, usedDefaultSlugs, slugifyPublicGroup) so the
conditional is consistent and the repeated property access is eliminated.

In `@src/lib/public-status/config.ts`:
- Around line 205-244: The loop that builds a non-colliding slug is duplicated
in createUniquePublicGroupSlug and createCollisionPublicGroupSlug; extract a
shared helper (e.g., resolveSlugWithStableSuffix(baseSlug: string, groupName:
string, usedSlugs: Set<string>) => string) that encapsulates the while loop
using appendStablePublicGroupSlugSuffix, createStablePublicGroupSlugSuffix and
PUBLIC_STATUS_SLUG_FALLBACK_PREFIX, then have createCollisionPublicGroupSlug
call that helper and have createUniquePublicGroupSlug use it for the collision
case (retaining the early-return when baseSlug is unused and ensuring
createUniquePublicGroupSlug still adds the final candidate to usedSlugs before
returning).

In `@tests/unit/public-status/config-publisher.test.ts`:
- Around line 280-328: Add an assertion that verifies
mockPublishInternalPublicStatusConfigSnapshot is called with an internal
snapshot mapping the original sourceGroupName to the generated slug: after
calling publishCurrentPublicStatusConfigProjection, assert
mockPublishInternalPublicStatusConfigSnapshot was invoked with an
objectContaining({ snapshot: objectContaining({ groups: arrayContaining([
objectContaining({ sourceGroupName: "cc逆向", slug:
expect.stringMatching(/^cc-[a-z0-9]{6}$/) }) ]) }) }) so the
internally-published snapshot preserves the original group name and the same
generated slug used in mockPublishPublicStatusConfigSnapshot.

In `@tests/unit/settings/status-page/public-status-settings-form.test.tsx`:
- Around line 379-440: The test mutates HTMLElement.prototype.scrollIntoView and
installs a spy on window.requestAnimationFrame without guaranteed cleanup; wrap
the prototypal override and the requestAnimationFrame spy in a try/finally (or
move the setup to a beforeEach and the cleanup to an afterEach) so that
HTMLElement.prototype.scrollIntoView is restored and
requestAnimationFrame.mockRestore() is always called even if assertions fail;
refer to the symbols HTMLElement.prototype.scrollIntoView, requestAnimationFrame
(vi.spyOn(window, "requestAnimationFrame")), and
requestAnimationFrame.mockRestore() to locate and implement the safe teardown.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 16947309-6919-47af-bab9-5106d7819877

📥 Commits

Reviewing files that changed from the base of the PR and between 3d0d06d and 275c2fb.

📒 Files selected for processing (13)
  • messages/en/settings/statusPage.json
  • messages/ja/settings/statusPage.json
  • messages/ru/settings/statusPage.json
  • messages/zh-CN/settings/statusPage.json
  • messages/zh-TW/settings/statusPage.json
  • src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx
  • src/app/[locale]/settings/status-page/loader.ts
  • src/lib/public-status/config-publisher.ts
  • src/lib/public-status/config.ts
  • tests/unit/public-status/config-publisher.test.ts
  • tests/unit/public-status/public-status-config.test.ts
  • tests/unit/settings/status-page/public-status-settings-form.test.tsx
  • tests/unit/settings/status-page/status-page-loader.test.tsx

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR addresses a real issue with slug collisions for non-English group names and provides a three-layer fix. The implementation is generally sound but has two issues that should be addressed: a code duplication that could create maintenance burden, and an edge case in the loader that could produce initial state collisions.

PR Size: M

  • Lines changed: 575 (539 additions, 36 deletions)
  • Files changed: 13
  • Note: Size label is size/L but by the table this should be size/M (575 < 1000 lines, 13 < 30 files)

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 1 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 1 0

High Priority Issues (Should Fix)

[HIGH] loader.ts: Single-pass ordering can produce initial slug collisions

In src/app/[locale]/settings/status-page/loader.ts, the single-pass approach adds explicit slugs to usedDefaultSlugs only after processing each group. This means an earlier group without a stored slug can grab a base slug that collides with a later group's explicit slug.

Example: If Group A (no slug, name="cc") is processed first, it gets slug "cc". Then Group B (stored slug="cc") is processed and also returns "cc", leaving the form in an already-conflicting state that blocks saving.

Suggested fix: Use a two-pass approach—first collect all normalized explicit slugs into usedDefaultSlugs, then run auto-generation.

// First pass: collect explicit slugs
const explicitSlugs = new Set<string>();
for (const group of groups) {
  const parsed = parsePublicStatusDescription(group.description);
  if (parsed.publicStatus?.publicGroupSlug) {
    const normalized = slugifyPublicGroup(parsed.publicStatus.publicGroupSlug);
    if (normalized) explicitSlugs.add(normalized);
  }
}

// Second pass: generate unique defaults with explicit slugs pre-seeded
const usedDefaultSlugs = new Set<string>(explicitSlugs);
// ... rest of mapping logic

Medium Priority Issues (Consider Fixing)

[MEDIUM] config.ts: createCollisionPublicGroupSlug duplicates createUniquePublicGroupSlug's loop body

The while-loop logic in createCollisionPublicGroupSlug (lines 392-409) is nearly identical to createUniquePublicGroupSlug (lines 370-390). The only differences are: (1) createUniquePublicGroupSlug handles the no-collision fast path and adds to usedSlugs, and (2) createCollisionPublicGroupSlug takes a baseSlug parameter instead of deriving it from groupName.

Suggested fix: Extract a shared private helper to eliminate the duplication:

function generateUniqueSlugWithSuffix(
  baseSlug: string,
  groupName: string,
  usedSlugs: Set<string>
): string {
  let counter = 1;
  let candidate = baseSlug;
  while (usedSlugs.has(candidate)) {
    const suffixSource = counter === 1 ? groupName : `${groupName}-${counter}`;
    candidate = appendStablePublicGroupSlugSuffix(
      baseSlug || PUBLIC_STATUS_SLUG_FALLBACK_PREFIX,
      createStablePublicGroupSlugSuffix(suffixSource)
    );
    counter += 1;
  }
  return candidate;
}

Then both createUniquePublicGroupSlug and createCollisionPublicGroupSlug can delegate to this helper.

Review Coverage

  • Logic and correctness - 1 issue found (loader ordering edge case)
  • Security (OWASP Top 10) - Clean
  • Error handling - Clean (no silent failures)
  • Type safety - Clean
  • Documentation accuracy - Clean
  • Test coverage - Adequate (13 new tests added)
  • Code clarity - 1 issue found (duplicated loop logic)

Additional Notes

  1. i18n Compliance: All 5 languages properly updated with duplicateSlug message (per CLAUDE.md i18n rule)
  2. Test Coverage: New tests cover non-English slug generation, duplicate detection, Redis projection with legacy slugs, and UI validation
  3. Pre-commit Checklist: PR description indicates all checks passed

Automated review by Claude AI

initialAggregationIntervalMinutes: settings.publicStatusAggregationIntervalMinutes,
initialGroups: groups.map((group) => {
const parsed = parsePublicStatusDescription(group.description);
const publicGroupSlug =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] Single-pass ordering can produce initial slug collisions

The current logic adds explicit stored slugs to usedDefaultSlugs only after their group is processed. This means an earlier group without a stored slug can grab a base slug that collides with a later group's explicit slug.

Example scenario:

  1. Group A: no stored slug, name normalizes to "cc" → gets "cc"
  2. usedDefaultSlugs = {"cc"}
  3. Group B: stored slug="cc" → also returns "cc"
  4. Form loads with both groups having slug "cc", blocking save

Suggested fix: Use a two-pass approach—first collect all normalized explicit slugs, then run auto-generation with those slugs pre-seeded in usedDefaultSlugs.


return appendStablePublicGroupSlugSuffix(asciiSlug, suffix);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Duplicate while-loop logic

This while-loop is nearly identical to the one in createUniquePublicGroupSlug (lines 370-390). Both:

  • Initialize counter = 1 and candidate = baseSlug
  • Use the same suffix generation logic
  • Have identical loop conditions

The only meaningful difference is that createUniquePublicGroupSlug adds to usedSlugs while this function returns the candidate for the caller to add.

Suggested fix: Extract a shared private helper generateUniqueSlugWithSuffix() to eliminate the ~15 lines of duplicated logic.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

No significant issues identified in the changed lines. Reviewed slug normalization, duplicate handling, UI validation behavior, and accompanying unit tests for regression coverage.

PR Size: L

  • Lines changed: 575
  • Files changed: 13
  • Split suggestion: Consider separating into (1) slug generation + projection fallback changes in src/lib/public-status/, (2) settings UI validation + i18n strings, (3) unit tests.

Issues Found

Category Critical High Medium Low
Logic/Bugs 0 0 0 0
Security 0 0 0 0
Error Handling 0 0 0 0
Types 0 0 0 0
Comments/Docs 0 0 0 0
Tests 0 0 0 0
Simplification 0 0 0 0

Critical Issues (Must Fix)

None.

High Priority Issues (Should Fix)

None.

Review Coverage

  • Logic and correctness
  • Security (OWASP Top 10)
  • Error handling
  • Type safety
  • Documentation accuracy
  • Test coverage
  • Code clarity

Automated review by Codex AI

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Identified open PR #1102 (fix(public-status): prevent duplicate group slugs) from the GitHub Actions event payload.
  • Applied PR size label: size/L (575 lines changed across 13 files).
  • Completed 6-perspective diff-focused review and submitted the required summary via gh pr review --comment.
  • No inline review comments were posted (no diff-line issues met the reporting threshold after validation).

@github-actions
Copy link
Copy Markdown
Contributor

🧪 测试结果

测试类型 状态
代码质量
单元测试
集成测试
API 测试

总体结果: ✅ 所有测试通过

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/unit/settings/status-page/public-status-settings-form.test.tsx (2)

488-493: getGroupToggleButton 使用 textContent.includes(groupName) 存在误匹配风险。

"openai-primary""openai-fallback" 之外的另一个组名,但若其他按钮(例如保存/折叠图标按钮的 aria-label 或父级容器)也包含该字符串,Array.find 会取到第一个匹配项,可能并非真正的折叠切换按钮。建议改为基于 data-testid 或更精确的选择器(例如要求按钮文本完全等于 groupName)来定位,避免后续 UI 文案变化导致测试静默失效。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/status-page/public-status-settings-form.test.tsx` around
lines 488 - 493, getGroupToggleButton currently uses
textContent?.includes(groupName) which can false-match other buttons; update
getGroupToggleButton to target the actual group toggle precisely (e.g., query
container.querySelectorAll("button") but match textContent.trim() === groupName
or, better, select by a dedicated data-testid or aria-label on the group
toggle). Locate the helper function getGroupToggleButton and replace the loose
includes() check with an exact text match or a data-testid/aria selector tied to
the group toggle button (ensuring tests use that attribute where the component
renders).

384-387: 建议在测试结束后还原 HTMLElement.prototype.scrollIntoView

三个新增用例均通过 Object.defineProperty 直接修改 HTMLElement.prototype 上的 scrollIntoView,但只对 requestAnimationFrame 间谍执行了 mockRestore,原型上的猴补丁残留可能影响同 worker 中后续运行的其他测试文件。建议在 afterEach 中删除该属性,或仅在容器元素实例上设置 scrollIntoView

♻️ 建议改动
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@
 describe("public-status settings form", () => {
   beforeEach(() => {
     vi.clearAllMocks();
     ...
   });
+
+  afterEach(() => {
+    delete (HTMLElement.prototype as { scrollIntoView?: unknown }).scrollIntoView;
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/settings/status-page/public-status-settings-form.test.tsx` around
lines 384 - 387, The tests mutate HTMLElement.prototype.scrollIntoView via
Object.defineProperty but never restore it, which can leak into other tests;
update the test file to clean up by restoring or deleting that property in an
afterEach hook (or, alternatively, avoid patching the prototype by defining
scrollIntoView only on the test container element), and ensure you still call
mockRestore on the requestAnimationFrame spy; reference the prototype mutation
(HTMLElement.prototype.scrollIntoView), the existing requestAnimationFrame
spy/mockRestore, and add an afterEach to delete or restore the prototype
property or scope the mock to the container element.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/unit/settings/status-page/public-status-settings-form.test.tsx`:
- Around line 488-493: getGroupToggleButton currently uses
textContent?.includes(groupName) which can false-match other buttons; update
getGroupToggleButton to target the actual group toggle precisely (e.g., query
container.querySelectorAll("button") but match textContent.trim() === groupName
or, better, select by a dedicated data-testid or aria-label on the group
toggle). Locate the helper function getGroupToggleButton and replace the loose
includes() check with an exact text match or a data-testid/aria selector tied to
the group toggle button (ensuring tests use that attribute where the component
renders).
- Around line 384-387: The tests mutate HTMLElement.prototype.scrollIntoView via
Object.defineProperty but never restore it, which can leak into other tests;
update the test file to clean up by restoring or deleting that property in an
afterEach hook (or, alternatively, avoid patching the prototype by defining
scrollIntoView only on the test container element), and ensure you still call
mockRestore on the requestAnimationFrame spy; reference the prototype mutation
(HTMLElement.prototype.scrollIntoView), the existing requestAnimationFrame
spy/mockRestore, and add an afterEach to delete or restore the prototype
property or scope the mock to the container element.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1320e7e3-5589-45f0-be25-27640a27a65c

📥 Commits

Reviewing files that changed from the base of the PR and between 275c2fb and e0307fb.

📒 Files selected for processing (5)
  • src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx
  • src/app/[locale]/settings/status-page/loader.ts
  • src/lib/public-status/config.ts
  • tests/unit/settings/status-page/public-status-settings-form.test.tsx
  • tests/unit/settings/status-page/status-page-loader.test.tsx
✅ Files skipped from review due to trivial changes (2)
  • src/app/[locale]/settings/status-page/_components/public-status-settings-form.tsx
  • src/lib/public-status/config.ts

@ding113 ding113 merged commit 4ec2d17 into dev Apr 25, 2026
11 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in Claude Code Hub Roadmap Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:i18n area:UI bug Something isn't working size/L Large PR (< 1000 lines)

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant