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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ bun run test:run # Vitest 単体テスト
## DB スキーマ変更(必読) / Database schema changes (must read)

- **TS スキーマと SQL マイグレーションは常に対で更新する**。`server/api/src/schema/**/*.ts` を編集したら、必ず `server/api/drizzle/NNNN_*.sql` を新規追加し、`server/api/drizzle/meta/_journal.json` にエントリを追記する。
_Always pair TS schema edits with a SQL migration: add a new `server/api/drizzle/NNNN_\*.sql`and append an entry to`server/api/drizzle/meta/_journal.json`. Skipping this caused PR #728 → production 500s on `/api/onboarding/status`and`/api/pages`._
Always pair TS schema edits with a SQL migration: add a new `server/api/drizzle/NNNN_*.sql` and append an entry to `server/api/drizzle/meta/_journal.json`. Skipping this caused production 500s in PR #728 on `/api/onboarding/status` and `/api/pages`.
- **正本のマイグレーション置き場は `server/api/drizzle/` のみ**。CI (`deploy-{dev,prod}.yml`) は `bunx drizzle-kit migrate` だけを実行するため、ここ以外に SQL を置いても本番には適用されない。
_Source of truth is `server/api/drizzle/`. CI runs only `bunx drizzle-kit migrate`; SQL placed elsewhere is dead code._
- **マイグレーションの書き方**:
Expand Down
3 changes: 2 additions & 1 deletion server/api/src/lib/extractPlainTextFromYXml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,6 @@ export const CONTENT_PREVIEW_MAX_LENGTH = 120;
export function buildContentPreview(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed;
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "...";
const headLength = Math.max(0, CONTENT_PREVIEW_MAX_LENGTH - 3);
return `${trimmed.slice(0, headLength).trim()}...`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

trimmed.slice(0, headLength) は UTF-16 コードユニット単位で文字列を切り出すため、サロゲートペア(絵文字や一部の漢字など)の途中で切断され、不正な文字列(文字化け)が発生する可能性があります。プレビュー用途では致命的ではありませんが、より堅牢にするには Intl.Segmenter を使用するか、切り出し位置がサロゲートペアの間でないかを確認する処理を追加することを検討してください。なお、この修正を行う場合は server/hocuspocus 側の同名ファイルも同様に更新する必要があります。

}
2 changes: 1 addition & 1 deletion server/hocuspocus/src/extractPlainTextFromYXml.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,6 @@ describe("buildContentPreview", () => {
const long = "x".repeat(200);
const prev = buildContentPreview(long);
expect(prev.endsWith("...")).toBe(true);
expect(prev.length).toBeLessThanOrEqual(124);
expect(prev.length).toBeLessThanOrEqual(120);
});
});
3 changes: 2 additions & 1 deletion server/hocuspocus/src/extractPlainTextFromYXml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@ export const CONTENT_PREVIEW_MAX_LENGTH = 120;
export function buildContentPreview(text: string): string {
const trimmed = text.trim().replace(/\s+/g, " ");
if (trimmed.length <= CONTENT_PREVIEW_MAX_LENGTH) return trimmed;
return trimmed.slice(0, CONTENT_PREVIEW_MAX_LENGTH).trim() + "...";
const headLength = Math.max(0, CONTENT_PREVIEW_MAX_LENGTH - 3);
return `${trimmed.slice(0, headLength).trim()}...`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

trimmed.slice(0, headLength) は UTF-16 コードユニット単位で文字列を切り出すため、サロゲートペア(絵文字や一部の漢字など)の途中で切断され、不正な文字列(文字化け)が発生する可能性があります。プレビュー用途では致命的ではありませんが、より堅牢にするには Intl.Segmenter を使用するか、切り出し位置がサロゲートペアの間でないかを確認する処理を追加することを検討してください。なお、この修正を行う場合は server/api 側の同名ファイルも同様に更新する必要があります。

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ vi.mock("@/hooks/usePageQueries", () => ({
checkExistence: vi.fn(async (titles: string[]) => {
const inScope = options?.pageNoteId ? (options.notePages ?? []) : [];
const pageTitles = new Set(inScope.map((page) => page.title.toLowerCase().trim()));
// issue #737: `pageTitleToId` を返すモック契約。テストは「resolution は
// するが id 解決は不要」のシナリオを想定しているため空 Map を返しても十分
// Mock contract for issue #737. The current scenarios only assert
// exists/referenced changes, so an empty map is fine.
// issue #737: `pageTitleToId` を返すモック契約。`targetId` 解決を伴う
// シナリオを検証できるよう、note スコープ内ページから title→id を構築する
// Mock contract for issue #737. Build a title→id map from in-scope
// pages so `targetId` resolution paths are testable.
const pageTitleToId = new Map<string, string>(
inScope.map((page) => [page.title.toLowerCase().trim(), page.id]),
);
Expand Down
15 changes: 10 additions & 5 deletions src/components/editor/extensions/TagExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,14 @@ describe("Tag extension configuration", () => {
if (typeof addAttributes !== "function") {
throw new Error("addAttributes must be a function");
}
const attrs = addAttributes.call({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(extension as any),
type AddAttributesContext = Record<string, unknown> & {
parent?: (() => Record<string, unknown>) | undefined;
};
const context: AddAttributesContext = {
...extension,
parent: undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any) as Record<string, unknown>;
};
const attrs = addAttributes.call(context) as Record<string, unknown>;
const targetId = attrs.targetId as ReturnType<typeof getTargetIdSpec>;
if (!targetId) throw new Error("targetId attribute missing");
return targetId;
Expand All @@ -243,6 +245,9 @@ describe("Tag extension configuration", () => {

const empty = document.createElement("span");
expect(spec.parseHTML(empty)).toBeNull();

empty.setAttribute("data-target-id", "");
expect(spec.parseHTML(empty)).toBeNull();
});

it("omits data-target-id when targetId is null or empty", () => {
Expand Down
7 changes: 6 additions & 1 deletion src/components/editor/extensions/TagExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ export const Tag = Mark.create<TagOptions>({
*/
targetId: {
default: null,
parseHTML: (element) => element.getAttribute("data-target-id"),
parseHTML: (element) => {
const raw = element.getAttribute("data-target-id");
if (typeof raw !== "string") return null;
const normalized = raw.trim();
return normalized.length > 0 ? normalized : null;
},
renderHTML: (attributes) => {
const value = attributes.targetId;
if (typeof value !== "string" || value.length === 0) {
Expand Down
15 changes: 10 additions & 5 deletions src/components/editor/extensions/WikiLinkExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ describe("WikiLinkExtension paste rule", () => {
if (typeof addAttributes !== "function") {
throw new Error("addAttributes must be a function");
}
const attrs = addAttributes.call({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(extension as any),
type AddAttributesContext = Record<string, unknown> & {
parent?: (() => Record<string, unknown>) | undefined;
};
const context: AddAttributesContext = {
...extension,
parent: undefined,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any) as Record<string, unknown>;
};
const attrs = addAttributes.call(context) as Record<string, unknown>;
const targetId = attrs.targetId as ReturnType<typeof getTargetIdSpec>;
if (!targetId) throw new Error("targetId attribute missing");
return targetId;
Expand All @@ -131,6 +133,9 @@ describe("WikiLinkExtension paste rule", () => {

const empty = document.createElement("span");
expect(spec.parseHTML(empty)).toBeNull();

empty.setAttribute("data-target-id", "");
expect(spec.parseHTML(empty)).toBeNull();
});

it("omits data-target-id when targetId is null or empty", () => {
Expand Down
7 changes: 6 additions & 1 deletion src/components/editor/extensions/WikiLinkExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,12 @@ export const WikiLink = Mark.create<WikiLinkOptions>({
*/
targetId: {
default: null,
parseHTML: (element) => element.getAttribute("data-target-id"),
parseHTML: (element) => {
const raw = element.getAttribute("data-target-id");
if (typeof raw !== "string") return null;
const normalized = raw.trim();
return normalized.length > 0 ? normalized : null;
},
renderHTML: (attributes) => {
const value = attributes.targetId;
if (typeof value !== "string" || value.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,11 +148,13 @@ describe("slashSuggestionPlugin — deactivation", () => {
const plugin = getPlugin(onStateChange);

// 1) Activate with `/foo`.
// 1) `/foo` でアクティブ化する。
let state = makeState("/foo", plugin);
expect(slashSuggestionPluginKey.getState(state)?.active).toBe(true);
onStateChange.mockClear();

// 2) Expand the selection to a range; the plugin must turn off.
// 2) 選択範囲をレンジに広げると、プラグインは非アクティブになる。
const tr = state.tr.setSelection(TextSelection.create(state.doc, 1, 5));
state = state.apply(tr);
const pluginState = slashSuggestionPluginKey.getState(state);
Expand Down Expand Up @@ -182,6 +184,7 @@ describe("slashSuggestionPlugin — deactivation", () => {
const plugin = getPlugin();

// Active first.
// まずアクティブ状態にする。
let state = makeState("/foo", plugin);
expect(slashSuggestionPluginKey.getState(state)?.active).toBe(true);

Expand Down
3 changes: 3 additions & 0 deletions src/hooks/useMermaidGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { useMermaidGenerator } from "./useMermaidGenerator";

// Safety net to keep spies (e.g. console.error if added in future tests) and
// any module-level setup from leaking between tests on assertion failures.
// assertion 失敗時でも spy やモジュールレベルの設定が次のテストへ漏れないようにする。
afterEach(() => {
vi.restoreAllMocks();
});
Expand Down Expand Up @@ -225,13 +226,15 @@ describe("useMermaidGenerator", () => {
const { result } = renderHook(() => useMermaidGenerator());

// Trigger an initial error first.
// まず初期エラーを発生させる。
await act(async () => {
await result.current.generate("", ["flowchart"]);
});
expect(result.current.status).toBe("error");

// Stub a long-running generation that resolves later so we can observe the
// transitional state.
// 遷移中の状態を観測できるよう、あとで resolve する長時間生成を stub する。
let resolveCb: (() => void) | null = null;
mockGenerateMermaidDiagram.mockImplementation(
(
Expand Down
1 change: 1 addition & 0 deletions src/hooks/useWorkflowDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ describe("useWorkflowDraft - import / export", () => {
expect(createObjectURLSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);

// キューされた setTimeout(..., 0) を実行して revokeObjectURL を発火させる。
// Flush the queued setTimeout(..., 0) that triggers revokeObjectURL.
vi.runAllTimers();
expect(revokeObjectURLSpy).toHaveBeenCalledWith("blob:mock-url");
Expand Down
5 changes: 5 additions & 0 deletions src/hooks/useWorkflowRunSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ describe("useWorkflowRunSession - resume", () => {

it("resume passes startStepIndex/stepOutputs/resumePartial back to runWorkflowExecution", async () => {
// First, drive a paused outcome so the hook captures pausedState.
// まず paused outcome を発生させ、hook が pausedState を保持する状態にする。
mockRunWorkflowExecution.mockResolvedValueOnce({
outcome: "paused",
pausedAtStepIndex: 1,
Expand Down Expand Up @@ -345,6 +346,7 @@ describe("useWorkflowRunSession - resume", () => {
await waitFor(() => expect(result.current.pausedState).not.toBeNull());

// User edited the draft and removed `s2`.
// ユーザーが draft を編集し、`s2` を削除したケース。
const editedDraft = makeDraft({
steps: [{ id: "s1", title: "Step One", instruction: "do" }],
});
Expand All @@ -359,6 +361,7 @@ describe("useWorkflowRunSession - resume", () => {
variant: "destructive",
});
// After aborting, pausedState is reset to null.
// abort 後、pausedState が null にリセットされる。
expect(result.current.pausedState).toBeNull();
});
});
Expand Down Expand Up @@ -401,6 +404,7 @@ describe("useWorkflowRunSession - cleanup and signals", () => {
expect(capturedStepAbort?.signal.aborted).toBe(true);

// Drain the promise so the test does not leak.
// テストリークを防ぐために Promise を解放する。
await act(async () => {
resolveExecution?.({ outcome: "completed" });
await pending;
Expand Down Expand Up @@ -482,6 +486,7 @@ describe("useWorkflowRunSession - cleanup and signals", () => {
expect(capturedStepAbort?.signal.aborted).toBe(true);

// Resolve to avoid leaking the pending promise into the next test.
// 次のテストへ pending Promise が漏れないように resolve する。
resolveExecution?.({ outcome: "stopped" });
await pending;
});
Expand Down
2 changes: 2 additions & 0 deletions src/lib/aiServiceServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,12 @@ describe("aiServiceServer / callAIWithServer", () => {
pulledOnce = true;
controller.enqueue(encoder.encode('data: {"content":"a"}\n'));
// 次の pull の前に abort
// Abort before the next pull.
abortController.abort();
return;
Comment on lines +361 to 363

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

この pull メソッド内での abort() 呼び出し直後に controller.close() を実行することを検討してください。現在の実装では、2 回目の pull が呼ばれた際に close() されるようになっていますが、コンシューマ(callAIWithServer)が abort を検知してストリームの読み取りを即座に停止した場合、2 回目の pull が発生せず、ストリームが閉じられないままハングやリークの原因になる可能性があります。この PR の目的である「ハング防止」をより確実にするための修正です。

            // Abort before the next pull.
            abortController.abort();
            controller.close();
            return;

}
controller.enqueue(encoder.encode(":\n"));
controller.close();
},
});
fetchSpy.mockResolvedValue(new Response(stream, { status: 200 }));
Expand Down
3 changes: 3 additions & 0 deletions src/stores/aiChatStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ describe("aiChatStore", () => {
// partialize されるので rehydrate 後に取り込まれない…はずが、zustand
// の persist はそのまま反映してしまう。partialize は書き込み側のみ。
// ここでは契約として「書き込み時に partialize される」ことを担保する。
// Even if volatile fields exist in storage, persist rehydrate can reflect them as-is.
// `partialize` only applies on write, so this test guards the write-side contract.
},
}),
);
Expand All @@ -188,6 +190,7 @@ describe("aiChatStore", () => {
expect(state.selectedModel).toEqual(model);
// CodeRabbit のレビュー対応: 揮発フィールドが rehydrate で蘇らないことを明示確認。
// Pin volatile fields explicitly so the test name matches its assertions.
// テスト名と検証内容の整合を保つため、揮発フィールドを明示的に検証する。
expect(state.activeConversationId).toBeNull();
expect(state.isStreaming).toBe(false);
expect(state.showConversationList).toBe(false);
Expand Down
Loading