@ripla/godd-mcp 1.0.7-canary.8 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +523 -124
- package/dist/godd.cjs +293 -244
- package/dist/godd.js +1005 -321
- package/dist/index.js +148 -119
- package/notes/app/DESIGN.md +5 -2
- package/notes/app/e2e/fixtures.ts +39 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts +185 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-desktop-1280-chromium-linux.png +0 -0
- package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-mobile-360-chromium-linux.png +0 -0
- package/notes/app/e2e/rtl-logical-properties.spec.ts +36 -0
- package/notes/app/package-lock.json +0 -8
- package/notes/app/package.json +0 -1
- package/notes/app/playwright.config.ts +7 -0
- package/notes/app/pnpm-lock.yaml +0 -8
- package/notes/app/src/components/BranchSelectorModal.tsx +8 -8
- package/notes/app/src/components/CommentPanel.tsx +5 -5
- package/notes/app/src/components/DiffPreview.tsx +4 -4
- package/notes/app/src/components/FileContentView.tsx +1 -1
- package/notes/app/src/components/FileContextMenu.tsx +1 -1
- package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +3 -35
- package/notes/app/src/components/IssueCommentThread.test.tsx +40 -19
- package/notes/app/src/components/IssueDetailView.test.tsx +3 -40
- package/notes/app/src/components/IssueList.tsx +3 -3
- package/notes/app/src/components/LabelSelector.tsx +1 -1
- package/notes/app/src/components/MarkdownEditor.tsx +1 -1
- package/notes/app/src/components/Skeleton.tsx +2 -2
- package/notes/app/src/components/ToastContainer.tsx +1 -1
- package/notes/app/src/components/TreeNode.tsx +6 -6
- package/notes/app/src/contexts/ToastContext.tsx +1 -1
- package/notes/app/src/hooks/useComments.ts +1 -1
- package/notes/app/src/hooks/useEditSession.ts +1 -1
- package/notes/app/src/lib/api.ts +1 -13
- package/notes/app/src/lib/csv-utils.ts +1 -1
- package/notes/app/src/lib/postLoginRedirect.ts +1 -1
- package/notes/app/src/lib/rtl-logical-properties.test.ts +59 -0
- package/notes/app/src/lib/user-colors.ts +1 -1
- package/notes/app/src/pages/MainPage.tsx +2 -2
- package/notes/app/src/pages/SettingsPage.test.tsx +40 -10
- package/notes/app/src/pages/SettingsPage.tsx +1 -1
- package/notes/app/src/pages/UserManagementPage.tsx +7 -7
- package/notes/app/src/styles/globals.css +17 -0
- package/notes/app/src/test/mockPeriodicSync.ts +68 -0
- package/notes/app/src/test/mocks.ts +1 -1
- package/package.json +7 -3
- package/templates/feedback/feedback-lambda/index.mjs +342 -0
- package/templates/feedback/frontend/FeedbackWidget.tsx +574 -0
- package/templates/feedback/frontend/api.ts +38 -0
- package/templates/feedback/frontend/composePinImage.ts +80 -0
- package/templates/feedback/frontend/config.ts +14 -0
- package/templates/feedback/frontend/types.ts +41 -0
- package/templates/feedback/terraform/Makefile +43 -0
- package/templates/feedback/terraform/backend.tf +10 -0
- package/templates/feedback/terraform/lambda.tf +95 -0
- package/templates/feedback/terraform/local.tf +12 -0
- package/templates/feedback/terraform/output.tf +9 -0
- package/templates/feedback/terraform/provider.tf +24 -0
- package/templates/feedback/terraform/variables.tf +25 -0
- package/templates/github-actions/aws/deploy-notes.yml.hbs +193 -10
- package/templates/notes-docs/001_project/README.md +0 -2
- package/templates/notes-docs/002_business_flow/README.md +19 -0
- package/templates/notes-docs/003_requirements/README.md +20 -0
- package/templates/notes-docs/003_requirements/questions.csv +1 -0
- package/templates/notes-docs/003_requirements/spec/README.md +11 -0
- package/templates/notes-docs/004_pages/README.md +22 -0
- package/templates/notes-docs/005_architecture/README.md +20 -0
- package/templates/notes-docs/006_development_workflow/README.md +67 -0
- package/templates/notes-docs/006_development_workflow/daily_flow.template.md +303 -0
- package/templates/notes-docs/006_development_workflow/git.template.md +72 -20
- package/templates/notes-docs/006_development_workflow/judgment.template.md +182 -0
- package/templates/notes-docs/006_development_workflow/workflow.template.md +167 -192
- package/templates/notes-docs/007_guides/README.md +85 -0
- package/templates/notes-docs/007_guides/bot-token-setup.md +174 -0
- package/templates/notes-docs/007_guides/getting_started.md +112 -0
- package/templates/notes-docs/007_guides/glossary.md +145 -0
- package/templates/notes-docs/007_guides/godd_notes_features.md +105 -0
- package/templates/notes-docs/007_guides/godd_notes_usage.md +28 -19
- package/templates/notes-docs/007_guides/godd_tools.md +129 -0
- package/templates/notes-docs/007_guides/godd_usage.md +88 -95
- package/templates/notes-docs/007_guides/infra/README.md +57 -0
- package/templates/notes-docs/007_guides/{setup_aws_accounts.md → infra/setup_aws_accounts.md} +3 -2
- package/templates/notes-docs/007_guides/{setup_infra.template.md → infra/setup_infra.template.md} +15 -4
- package/templates/notes-docs/007_guides/setup_godd.md +73 -22
- package/templates/notes-docs/007_guides/setup_godd_notes.md +97 -42
- package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +2 -2
- package/templates/notes-docs/007_guides/setup_local_environment.template.md +81 -20
- package/templates/notes-docs/007_guides/troubleshooting.md +225 -0
- package/templates/notes-docs/999_ref/README.md +23 -0
- package/templates/notes-docs/GENERATED.md +1 -1
- package/templates/notes-docs/README.md +64 -34
- package/templates/terraform/aws/iam.tf.hbs +361 -4
- /package/templates/notes-docs/007_guides/{setup_aws_cost_alert.md → infra/setup_aws_cost_alert.md} +0 -0
- /package/templates/notes-docs/007_guides/{setup_aws_guard_duty.md → infra/setup_aws_guard_duty.md} +0 -0
|
@@ -27,6 +27,10 @@ import IssueCommentThread from "./IssueCommentThread";
|
|
|
27
27
|
const mockList = vi.mocked(listIssueCommentsApi);
|
|
28
28
|
const mockCreate = vi.mocked(createIssueCommentApi);
|
|
29
29
|
|
|
30
|
+
// Docker のフルスイート実行では 47 ファイル並列でリソースが競合し、既定の 1000ms を
|
|
31
|
+
// 超えて非同期解決することがある(#1143)。CI に耐える余裕を持たせるため明示指定する。
|
|
32
|
+
const WAIT_FOR_TIMEOUT = 3000;
|
|
33
|
+
|
|
30
34
|
const COMMENT = {
|
|
31
35
|
id: 1001,
|
|
32
36
|
body: "Hello from GitHub",
|
|
@@ -45,49 +49,66 @@ describe("IssueCommentThread", () => {
|
|
|
45
49
|
it("コメント一覧を時系列で表示する", async () => {
|
|
46
50
|
mockList.mockResolvedValue({ comments: [COMMENT] });
|
|
47
51
|
render(<IssueCommentThread issueNumber={42} canEdit={true} />);
|
|
48
|
-
await waitFor(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
await waitFor(
|
|
53
|
+
() => {
|
|
54
|
+
expect(screen.getByText("alice")).toBeTruthy();
|
|
55
|
+
expect(screen.getByTestId("comment-body").textContent).toBe("Hello from GitHub");
|
|
56
|
+
},
|
|
57
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
58
|
+
);
|
|
52
59
|
});
|
|
53
60
|
|
|
54
61
|
it("canEdit=true のとき投稿フォームを表示する", async () => {
|
|
55
62
|
render(<IssueCommentThread issueNumber={42} canEdit={true} />);
|
|
56
|
-
await waitFor(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
await waitFor(
|
|
64
|
+
() => {
|
|
65
|
+
expect(screen.getByPlaceholderText("Markdown でコメントを入力…")).toBeTruthy();
|
|
66
|
+
expect(screen.getByText("コメント投稿")).toBeTruthy();
|
|
67
|
+
},
|
|
68
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
69
|
+
);
|
|
60
70
|
});
|
|
61
71
|
|
|
62
72
|
it("canEdit=false のとき投稿フォームを非表示にする", async () => {
|
|
63
73
|
render(<IssueCommentThread issueNumber={42} canEdit={false} />);
|
|
64
|
-
await waitFor(
|
|
65
|
-
|
|
66
|
-
|
|
74
|
+
await waitFor(
|
|
75
|
+
() => {
|
|
76
|
+
expect(screen.queryByPlaceholderText("Markdown でコメントを入力…")).toBeNull();
|
|
77
|
+
},
|
|
78
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
79
|
+
);
|
|
67
80
|
});
|
|
68
81
|
|
|
69
82
|
it("コメント投稿成功後に一覧へ追加する", async () => {
|
|
70
83
|
const newComment = { ...COMMENT, id: 1002, body: "Posted" };
|
|
71
84
|
mockCreate.mockResolvedValue(newComment);
|
|
72
85
|
render(<IssueCommentThread issueNumber={42} canEdit={true} />);
|
|
73
|
-
await waitFor(() => expect(screen.getByText("コメント投稿")).toBeTruthy()
|
|
86
|
+
await waitFor(() => expect(screen.getByText("コメント投稿")).toBeTruthy(), {
|
|
87
|
+
timeout: WAIT_FOR_TIMEOUT,
|
|
88
|
+
});
|
|
74
89
|
|
|
75
90
|
fireEvent.change(screen.getByPlaceholderText("Markdown でコメントを入力…"), {
|
|
76
91
|
target: { value: "Posted" },
|
|
77
92
|
});
|
|
78
93
|
fireEvent.click(screen.getByText("コメント投稿"));
|
|
79
94
|
|
|
80
|
-
await waitFor(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
await waitFor(
|
|
96
|
+
() => {
|
|
97
|
+
expect(mockCreate).toHaveBeenCalledWith(42, "Posted");
|
|
98
|
+
expect(screen.getByText("Posted")).toBeTruthy();
|
|
99
|
+
},
|
|
100
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
101
|
+
);
|
|
84
102
|
});
|
|
85
103
|
|
|
86
104
|
it("読み込み失敗時にエラーメッセージを表示する", async () => {
|
|
87
105
|
mockList.mockRejectedValue(new Error("GitHub API unavailable"));
|
|
88
106
|
render(<IssueCommentThread issueNumber={42} canEdit={true} />);
|
|
89
|
-
await waitFor(
|
|
90
|
-
|
|
91
|
-
|
|
107
|
+
await waitFor(
|
|
108
|
+
() => {
|
|
109
|
+
expect(screen.getByText("GitHub API unavailable")).toBeTruthy();
|
|
110
|
+
},
|
|
111
|
+
{ timeout: WAIT_FOR_TIMEOUT },
|
|
112
|
+
);
|
|
92
113
|
});
|
|
93
114
|
});
|
|
@@ -22,47 +22,10 @@ vi.mock("@/contexts/ToastContext", () => ({
|
|
|
22
22
|
|
|
23
23
|
const triggerPeriodicSync = vi.hoisted(() => vi.fn());
|
|
24
24
|
vi.mock("@/hooks/usePeriodicSync", () => {
|
|
25
|
-
//
|
|
26
|
-
// onSync を ref 捕捉し、effect 依存は実フックと同じ [enabled, intervalMs, skipInitialSync]。
|
|
27
|
-
// IssueDetailView は skipInitialSync=true(#929)を渡すため、本番同様マウント時の即時 poll は抑制され、
|
|
28
|
-
// mount では foreground loadIssue のみが走る。これにより従来の mock が注入していた「phantom mount poll」
|
|
29
|
-
// (本番には存在しない 2 本目の in-flight fetch)が無くなり、triggered poll との競合(#980 / #1031 で
|
|
30
|
-
// setTimeout flush 緩和していた flaky の根本原因)が解消する。
|
|
31
|
-
// 非モデル化(意図的): 実フックの isSyncing(in-flight 中の tick スキップ)/ visibilitychange(非表示中停止)/
|
|
32
|
-
// setInterval は再現しない。poll は triggerPeriodicSync で明示発火するテストシームのため不要。これらの
|
|
33
|
-
// 挙動に依存するテストを足す場合は本 mock の拡張が必要(現行テストはいずれも未依存)。
|
|
25
|
+
// vi.mock factory は hoist されるため、共有ヘルパも factory 内で解決する。
|
|
34
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- mock factory 制約
|
|
35
|
-
const {
|
|
36
|
-
return
|
|
37
|
-
usePeriodicSync: ({
|
|
38
|
-
onSync,
|
|
39
|
-
enabled,
|
|
40
|
-
intervalMs,
|
|
41
|
-
skipInitialSync,
|
|
42
|
-
}: {
|
|
43
|
-
onSync: () => void;
|
|
44
|
-
enabled: boolean;
|
|
45
|
-
intervalMs: number;
|
|
46
|
-
skipInitialSync?: boolean;
|
|
47
|
-
}) => {
|
|
48
|
-
const onSyncRef = useRef(onSync);
|
|
49
|
-
const isInitialMountRef = useRef(true);
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
onSyncRef.current = onSync;
|
|
52
|
-
}, [onSync]);
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
// trigger は常に最新 onSync を呼ぶ(mock 差し替え後の closed 応答を確実に拾う)。
|
|
55
|
-
triggerPeriodicSync.mockImplementation(() => {
|
|
56
|
-
if (enabled) onSyncRef.current();
|
|
57
|
-
});
|
|
58
|
-
const isInitialMount = isInitialMountRef.current;
|
|
59
|
-
isInitialMountRef.current = false;
|
|
60
|
-
if (!enabled || intervalMs <= 0) return;
|
|
61
|
-
if (skipInitialSync && isInitialMount) return;
|
|
62
|
-
onSyncRef.current();
|
|
63
|
-
}, [enabled, intervalMs, skipInitialSync]);
|
|
64
|
-
},
|
|
65
|
-
};
|
|
27
|
+
const { faithfulPeriodicSyncMock } = require("../test/mockPeriodicSync.ts") as typeof import("../test/mockPeriodicSync");
|
|
28
|
+
return faithfulPeriodicSyncMock(triggerPeriodicSync);
|
|
66
29
|
});
|
|
67
30
|
|
|
68
31
|
vi.mock("@/components/FileContentView", () => ({
|
|
@@ -223,7 +223,7 @@ export default function IssueList({
|
|
|
223
223
|
: "text-gray-400 hover:text-gray-200"
|
|
224
224
|
}`}
|
|
225
225
|
>
|
|
226
|
-
<CircleDot className="w-3 h-3 inline
|
|
226
|
+
<CircleDot className="w-3 h-3 inline me-1" />
|
|
227
227
|
Open
|
|
228
228
|
</button>
|
|
229
229
|
<button
|
|
@@ -235,7 +235,7 @@ export default function IssueList({
|
|
|
235
235
|
: "text-gray-400 hover:text-gray-200"
|
|
236
236
|
}`}
|
|
237
237
|
>
|
|
238
|
-
<CircleCheck className="w-3 h-3 inline
|
|
238
|
+
<CircleCheck className="w-3 h-3 inline me-1" />
|
|
239
239
|
Closed
|
|
240
240
|
</button>
|
|
241
241
|
</div>
|
|
@@ -318,7 +318,7 @@ export default function IssueList({
|
|
|
318
318
|
key={issue.number}
|
|
319
319
|
type="button"
|
|
320
320
|
onClick={() => onSelect(issue.number)}
|
|
321
|
-
className={`w-full text-
|
|
321
|
+
className={`w-full text-start px-2 rounded text-xs sm:text-xs mb-0.5 transition-colors ${ISSUE_MOBILE_LIST_ITEM} ${
|
|
322
322
|
selectedIssue === issue.number
|
|
323
323
|
? "bg-blue-600/20 text-blue-300"
|
|
324
324
|
: "text-gray-300 hover:bg-gray-700/50"
|
|
@@ -307,7 +307,7 @@ export default function LabelSelector({
|
|
|
307
307
|
style={{ backgroundColor: `#${label.color}` }}
|
|
308
308
|
/>
|
|
309
309
|
<span
|
|
310
|
-
className="flex-1 text-
|
|
310
|
+
className="flex-1 text-start truncate"
|
|
311
311
|
style={{ color: `#${label.color}` }}
|
|
312
312
|
>
|
|
313
313
|
{label.name}
|
|
@@ -770,7 +770,7 @@ export default function MarkdownEditor({
|
|
|
770
770
|
<button
|
|
771
771
|
type="button"
|
|
772
772
|
onClick={() => setShowSearch(false)}
|
|
773
|
-
className="text-gray-500 hover:text-gray-300 text-xs
|
|
773
|
+
className="text-gray-500 hover:text-gray-300 text-xs ms-auto"
|
|
774
774
|
aria-label="閉じる"
|
|
775
775
|
>
|
|
776
776
|
<X className="w-3 h-3" />
|
|
@@ -11,13 +11,13 @@ export function TreeSkeleton() {
|
|
|
11
11
|
return (
|
|
12
12
|
<div className="space-y-2 p-2">
|
|
13
13
|
<SkeletonLine width="60%" />
|
|
14
|
-
<div className="
|
|
14
|
+
<div className="ms-3 space-y-2">
|
|
15
15
|
<SkeletonLine width="80%" />
|
|
16
16
|
<SkeletonLine width="70%" />
|
|
17
17
|
<SkeletonLine width="75%" />
|
|
18
18
|
</div>
|
|
19
19
|
<SkeletonLine width="55%" />
|
|
20
|
-
<div className="
|
|
20
|
+
<div className="ms-3 space-y-2">
|
|
21
21
|
<SkeletonLine width="65%" />
|
|
22
22
|
<SkeletonLine width="85%" />
|
|
23
23
|
</div>
|
|
@@ -16,7 +16,7 @@ export default function ToastContainer() {
|
|
|
16
16
|
if (toasts.length === 0) return null;
|
|
17
17
|
|
|
18
18
|
return (
|
|
19
|
-
<div className="fixed bottom-4
|
|
19
|
+
<div className="fixed bottom-4 end-4 z-50 flex flex-col gap-2 max-w-sm">
|
|
20
20
|
{toasts.map((toast) => {
|
|
21
21
|
const style = STYLE_MAP[toast.type];
|
|
22
22
|
return (
|
|
@@ -2,8 +2,8 @@ import { useState, useEffect } from "react";
|
|
|
2
2
|
import { ChevronRight, ChevronDown, Folder, FolderOpen, File } from "lucide-react";
|
|
3
3
|
import type { TreeEntry } from "@/types";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const DND_MIME = "application/x-notes-filepath";
|
|
6
|
+
const DND_TYPE_MIME = "application/x-notes-entrytype";
|
|
7
7
|
|
|
8
8
|
const HIDDEN_FILES = new Set([".gitkeep"]);
|
|
9
9
|
|
|
@@ -95,14 +95,14 @@ export default function TreeNode({
|
|
|
95
95
|
onDragOver={handleDragOver}
|
|
96
96
|
onDragLeave={handleDragLeave}
|
|
97
97
|
onDrop={handleDrop}
|
|
98
|
-
className={`w-full text-
|
|
98
|
+
className={`w-full text-start py-1 px-2 text-sm rounded flex items-center gap-1 transition-colors ${
|
|
99
99
|
dropOver
|
|
100
100
|
? "bg-blue-700/40 ring-1 ring-blue-500"
|
|
101
101
|
: isActiveDir
|
|
102
102
|
? "bg-blue-600/30 text-blue-200"
|
|
103
103
|
: "hover:bg-gray-700"
|
|
104
104
|
}`}
|
|
105
|
-
style={{
|
|
105
|
+
style={{ paddingInlineStart: pl }}
|
|
106
106
|
>
|
|
107
107
|
{open
|
|
108
108
|
? <ChevronDown className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
|
@@ -142,10 +142,10 @@ export default function TreeNode({
|
|
|
142
142
|
onDragStart={canDrag ? handleDragStart : undefined}
|
|
143
143
|
onClick={() => onSelect(entry.path)}
|
|
144
144
|
onContextMenu={handleCtx}
|
|
145
|
-
className={`w-full text-
|
|
145
|
+
className={`w-full text-start py-1 px-2 text-sm rounded flex items-center gap-1 ${
|
|
146
146
|
isSelected ? "bg-blue-600 text-white" : "hover:bg-gray-700"
|
|
147
147
|
}`}
|
|
148
|
-
style={{
|
|
148
|
+
style={{ paddingInlineStart: pl }}
|
|
149
149
|
>
|
|
150
150
|
<span className="w-3.5 shrink-0" />
|
|
151
151
|
<File className="w-4 h-4 text-gray-400 shrink-0" />
|
|
@@ -4,7 +4,7 @@ import { usePeriodicSync } from "@/hooks/usePeriodicSync";
|
|
|
4
4
|
import { GITHUB_SYNC_POLL_INTERVAL_MS } from "@/lib/github-sync";
|
|
5
5
|
|
|
6
6
|
/** `/pr/my-session` が GitHub から付与する PR のライブ状態(DB の prNumber/prUrl は保持) */
|
|
7
|
-
|
|
7
|
+
type SessionPrGithub = {
|
|
8
8
|
number: number;
|
|
9
9
|
url: string;
|
|
10
10
|
state: string;
|
package/notes/app/src/lib/api.ts
CHANGED
|
@@ -268,18 +268,6 @@ export async function fetchHistoryApi(
|
|
|
268
268
|
return res.json();
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
export async function fetchFileAtRefApi(
|
|
272
|
-
path: string,
|
|
273
|
-
ref: string,
|
|
274
|
-
): Promise<{ content: string; sha: string }> {
|
|
275
|
-
const res = await apiFetch(`/files/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
|
|
276
|
-
if (!res.ok) {
|
|
277
|
-
const body = await res.json().catch(() => null);
|
|
278
|
-
throw new Error(formatApiDetail(body, `Failed to fetch file at ref: ${res.status}`));
|
|
279
|
-
}
|
|
280
|
-
return res.json();
|
|
281
|
-
}
|
|
282
|
-
|
|
283
271
|
export async function updateSettingApi(
|
|
284
272
|
key: string,
|
|
285
273
|
value: unknown,
|
|
@@ -783,7 +771,7 @@ export function isAbortError(error: unknown): boolean {
|
|
|
783
771
|
return false;
|
|
784
772
|
}
|
|
785
773
|
|
|
786
|
-
|
|
774
|
+
function formatApiDetail(body: unknown, fallback: string): string {
|
|
787
775
|
if (!body || typeof body !== "object") return fallback;
|
|
788
776
|
const detail = (body as Record<string, unknown>).detail;
|
|
789
777
|
if (typeof detail === "string") return detail;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Location } from "react-router-dom";
|
|
2
2
|
|
|
3
3
|
/** Passed from RequireAuth via react-router location.state */
|
|
4
|
-
|
|
4
|
+
type LoginRedirectState = { from?: { pathname: string; search?: string } };
|
|
5
5
|
|
|
6
6
|
/** Reject protocol-relative paths and non-internal targets (open-redirect hardening). */
|
|
7
7
|
function isSafeAppPath(pathname: string): boolean {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const SOURCE_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const CLASS_DELIMITER = String.fromCharCode(96);
|
|
8
|
+
const PHYSICAL_INLINE_CLASS = new RegExp(
|
|
9
|
+
"(?:^|[\\s\\\"" +
|
|
10
|
+
CLASS_DELIMITER +
|
|
11
|
+
"])" +
|
|
12
|
+
"(?:-?(?:pl|pr|ml|mr)-[^\\s\\\"" +
|
|
13
|
+
CLASS_DELIMITER +
|
|
14
|
+
"}]+|border-[lr](?:-[^\\s\\\"" +
|
|
15
|
+
CLASS_DELIMITER +
|
|
16
|
+
"}]+)?|rounded-[lr](?:-[^\\s\\\"" +
|
|
17
|
+
CLASS_DELIMITER +
|
|
18
|
+
"}]+)?|text-(?:left|right)|(?:left|right)-(?:auto|full|0|px|[0-9]|\\[))" +
|
|
19
|
+
"(?=$|[\\s\\\"" +
|
|
20
|
+
CLASS_DELIMITER +
|
|
21
|
+
"}])",
|
|
22
|
+
"gm",
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function findTsxFiles(directory: string): string[] {
|
|
26
|
+
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
27
|
+
const path = join(directory, entry.name);
|
|
28
|
+
if (entry.isDirectory()) return findTsxFiles(path);
|
|
29
|
+
if (!entry.name.endsWith(".tsx") || entry.name.endsWith(".test.tsx")) return [];
|
|
30
|
+
return [path];
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("Notes app の inline 方向クラス", () => {
|
|
35
|
+
it("コンテンツ要素へ物理 inline プロパティを再導入しない", () => {
|
|
36
|
+
const violations: string[] = [];
|
|
37
|
+
|
|
38
|
+
for (const file of findTsxFiles(SOURCE_DIRECTORY)) {
|
|
39
|
+
const source = readFileSync(file, "utf8");
|
|
40
|
+
for (const match of source.matchAll(PHYSICAL_INLINE_CLASS)) {
|
|
41
|
+
violations.push(file + ": " + match[0].trim());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
expect(violations).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("TreeNode の階層インデントは inline-start で指定する", () => {
|
|
49
|
+
const source = readFileSync(join(SOURCE_DIRECTORY, "components/TreeNode.tsx"), "utf8");
|
|
50
|
+
expect(source).toContain("paddingInlineStart");
|
|
51
|
+
expect(source).not.toContain("paddingLeft");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("inline-end に配置する Toast の入場方向も RTL で反転する", () => {
|
|
55
|
+
const styles = readFileSync(join(SOURCE_DIRECTORY, "styles/globals.css"), "utf8");
|
|
56
|
+
expect(styles).toContain("@keyframes slide-in-rtl");
|
|
57
|
+
expect(styles).toMatch(/:dir\(rtl\) \.animate-slide-in\s*\{\s*animation-name:\s*slide-in-rtl/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -979,7 +979,7 @@ export default function MainPage() {
|
|
|
979
979
|
<button
|
|
980
980
|
type="button"
|
|
981
981
|
onClick={logout}
|
|
982
|
-
className="text-gray-500 hover:text-gray-300
|
|
982
|
+
className="text-gray-500 hover:text-gray-300 ms-1"
|
|
983
983
|
title="ログアウト"
|
|
984
984
|
aria-label="ログアウト"
|
|
985
985
|
>
|
|
@@ -990,7 +990,7 @@ export default function MainPage() {
|
|
|
990
990
|
<button
|
|
991
991
|
type="button"
|
|
992
992
|
onClick={() => setSidebarOpen(false)}
|
|
993
|
-
className="text-gray-500 hover:text-gray-300
|
|
993
|
+
className="text-gray-500 hover:text-gray-300 ms-2 md:hidden"
|
|
994
994
|
title="閉じる"
|
|
995
995
|
aria-label="閉じる"
|
|
996
996
|
>
|
|
@@ -12,6 +12,10 @@ const MOCK_SETTINGS = {
|
|
|
12
12
|
lockout_duration_min: 15,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// CI の並列負荷で設定ロード・React の再描画が 1 秒を超えても、テストを早期に
|
|
16
|
+
// timeout させない。Vitest のテスト単位 timeout(既定 5 秒)内で失敗を返せる値にする。
|
|
17
|
+
const SETTINGS_WAIT_TIMEOUT_MS = 3_000;
|
|
18
|
+
|
|
15
19
|
const mockGetSettings = vi.fn().mockResolvedValue(MOCK_SETTINGS);
|
|
16
20
|
const mockUpdateSetting = vi.fn().mockImplementation(async (key: string, value: unknown) => ({
|
|
17
21
|
key,
|
|
@@ -46,8 +50,15 @@ function renderPage() {
|
|
|
46
50
|
* ので、タイムゾーンの反映が確認できれば他フィールドも反映済み。
|
|
47
51
|
*/
|
|
48
52
|
async function waitForSettledSettings() {
|
|
49
|
-
const tz = (await screen.findByLabelText(
|
|
50
|
-
|
|
53
|
+
const tz = (await screen.findByLabelText(
|
|
54
|
+
"タイムゾーン",
|
|
55
|
+
{},
|
|
56
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
57
|
+
)) as HTMLInputElement;
|
|
58
|
+
await waitFor(
|
|
59
|
+
() => expect(tz.value).toBe("Asia/Tokyo"),
|
|
60
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
61
|
+
);
|
|
51
62
|
return tz;
|
|
52
63
|
}
|
|
53
64
|
|
|
@@ -59,17 +70,31 @@ describe("SettingsPage", () => {
|
|
|
59
70
|
|
|
60
71
|
it("renders settings after loading", async () => {
|
|
61
72
|
renderPage();
|
|
62
|
-
await waitFor(
|
|
73
|
+
await waitFor(
|
|
74
|
+
() => expect(screen.getByText("一般設定")).toBeTruthy(),
|
|
75
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
76
|
+
);
|
|
63
77
|
expect(screen.getByText("セキュリティ設定")).toBeTruthy();
|
|
64
78
|
expect(screen.getByLabelText("タイムゾーン")).toBeTruthy();
|
|
65
79
|
});
|
|
66
80
|
|
|
67
81
|
it("shows back link to main page", async () => {
|
|
68
82
|
renderPage();
|
|
69
|
-
await waitFor(
|
|
83
|
+
await waitFor(
|
|
84
|
+
() => expect(screen.getByText("戻る")).toBeTruthy(),
|
|
85
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
86
|
+
);
|
|
70
87
|
});
|
|
71
88
|
|
|
72
89
|
it("calls updateSettingApi on save", async () => {
|
|
90
|
+
// 設定ロードが waitFor の既定 timeout(1 秒)をわずかに超える CI 負荷を再現する。
|
|
91
|
+
// 待機側に十分な明示 timeout がなければ、入力操作へ到達する前にテストが失敗する。
|
|
92
|
+
mockGetSettings.mockImplementationOnce(
|
|
93
|
+
() =>
|
|
94
|
+
new Promise((resolve) => {
|
|
95
|
+
setTimeout(() => resolve({ ...MOCK_SETTINGS }), 1_100);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
73
98
|
renderPage();
|
|
74
99
|
const input = await waitForSettledSettings();
|
|
75
100
|
|
|
@@ -77,17 +102,22 @@ describe("SettingsPage", () => {
|
|
|
77
102
|
fireEvent.change(input, { target: { value: "UTC" } });
|
|
78
103
|
});
|
|
79
104
|
|
|
80
|
-
await waitFor(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
105
|
+
await waitFor(
|
|
106
|
+
() => {
|
|
107
|
+
const btn = screen.getAllByText("保存")[0] as HTMLButtonElement;
|
|
108
|
+
expect(btn.disabled).toBe(false);
|
|
109
|
+
},
|
|
110
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
111
|
+
);
|
|
84
112
|
|
|
85
113
|
await act(async () => {
|
|
86
114
|
fireEvent.click(screen.getAllByText("保存")[0]);
|
|
87
115
|
});
|
|
88
116
|
|
|
89
|
-
await waitFor(
|
|
90
|
-
|
|
117
|
+
await waitFor(
|
|
118
|
+
() =>
|
|
119
|
+
expect(mockUpdateSetting).toHaveBeenCalledWith("timezone", "UTC"),
|
|
120
|
+
{ timeout: SETTINGS_WAIT_TIMEOUT_MS },
|
|
91
121
|
);
|
|
92
122
|
});
|
|
93
123
|
|
|
@@ -201,7 +201,7 @@ export default function SettingsPage() {
|
|
|
201
201
|
</Link>
|
|
202
202
|
<h1 className="text-base font-bold tracking-wide">設定</h1>
|
|
203
203
|
{!isAdmin && (
|
|
204
|
-
<span className="
|
|
204
|
+
<span className="ms-auto text-xs text-yellow-400 bg-yellow-900/30 border border-yellow-700/50 rounded px-2 py-0.5">
|
|
205
205
|
閲覧のみ
|
|
206
206
|
</span>
|
|
207
207
|
)}
|
|
@@ -303,7 +303,7 @@ function UserRow({
|
|
|
303
303
|
<span className="text-sm font-medium text-gray-100">
|
|
304
304
|
{user.displayName}
|
|
305
305
|
{isSelf && (
|
|
306
|
-
<span className="
|
|
306
|
+
<span className="ms-2 text-xs text-green-400 bg-green-900/30 border border-green-700/50 rounded px-1.5 py-0.5">
|
|
307
307
|
自分
|
|
308
308
|
</span>
|
|
309
309
|
)}
|
|
@@ -467,7 +467,7 @@ export default function UserManagementPage() {
|
|
|
467
467
|
<ArrowLeft className="inline w-4 h-4" /> 戻る
|
|
468
468
|
</Link>
|
|
469
469
|
<h1 className="text-base font-bold tracking-wide">ユーザー管理</h1>
|
|
470
|
-
<div className="
|
|
470
|
+
<div className="ms-auto">
|
|
471
471
|
<button
|
|
472
472
|
type="button"
|
|
473
473
|
onClick={() => setShowCreateModal(true)}
|
|
@@ -492,19 +492,19 @@ export default function UserManagementPage() {
|
|
|
492
492
|
<table className="w-full min-w-[600px]">
|
|
493
493
|
<thead>
|
|
494
494
|
<tr className="border-b border-gray-700 bg-gray-800/80">
|
|
495
|
-
<th className="px-4 py-2.5 text-
|
|
495
|
+
<th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
496
496
|
ユーザー
|
|
497
497
|
</th>
|
|
498
|
-
<th className="px-4 py-2.5 text-
|
|
498
|
+
<th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
499
499
|
ロール
|
|
500
500
|
</th>
|
|
501
|
-
<th className="px-4 py-2.5 text-
|
|
501
|
+
<th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
502
502
|
状態
|
|
503
503
|
</th>
|
|
504
|
-
<th className="px-4 py-2.5 text-
|
|
504
|
+
<th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
505
505
|
作成日
|
|
506
506
|
</th>
|
|
507
|
-
<th className="px-4 py-2.5 text-
|
|
507
|
+
<th className="px-4 py-2.5 text-end text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
|
508
508
|
操作
|
|
509
509
|
</th>
|
|
510
510
|
</tr>
|
|
@@ -88,6 +88,23 @@
|
|
|
88
88
|
animation: slide-in 0.25s ease-out;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/* Toast は inline-end に配置するため、RTL では入場方向も反転し、
|
|
92
|
+
論理位置と同じ物理端からスライドさせる。 */
|
|
93
|
+
@keyframes slide-in-rtl {
|
|
94
|
+
from {
|
|
95
|
+
transform: translateX(-100%);
|
|
96
|
+
opacity: 0;
|
|
97
|
+
}
|
|
98
|
+
to {
|
|
99
|
+
transform: translateX(0);
|
|
100
|
+
opacity: 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
:dir(rtl) .animate-slide-in {
|
|
105
|
+
animation-name: slide-in-rtl;
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
/* Syntax highlighting — Atom One Dark inspired */
|
|
92
109
|
.hljs-keyword,
|
|
93
110
|
.hljs-selector-tag { color: #c678dd; }
|