@cosmicdrift/kumiko-bundled-features 0.98.0 → 0.102.0

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.
Files changed (45) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/auth-mailer.ts +28 -12
  3. package/src/auth-email-password/index.ts +3 -0
  4. package/src/channel-email/__tests__/smtp-transport-from-env.test.ts +35 -0
  5. package/src/channel-email/email-channel.ts +32 -20
  6. package/src/channel-email/feature.ts +2 -0
  7. package/src/channel-email/index.ts +6 -1
  8. package/src/channel-email/smtp-transport.ts +37 -0
  9. package/src/channel-in-app/feature.ts +1 -0
  10. package/src/channel-in-app/in-app-channel.ts +1 -0
  11. package/src/channel-push/feature.ts +1 -0
  12. package/src/channel-push/push-channel.ts +1 -0
  13. package/src/delivery/__tests__/delivery.integration.test.ts +247 -1
  14. package/src/delivery/attempt-log.ts +54 -0
  15. package/src/delivery/channel-context.ts +17 -0
  16. package/src/delivery/constants.ts +35 -1
  17. package/src/delivery/delivery-service.ts +118 -47
  18. package/src/delivery/events.ts +7 -1
  19. package/src/delivery/feature.ts +38 -14
  20. package/src/delivery/index.ts +3 -0
  21. package/src/delivery/jobs.ts +153 -0
  22. package/src/delivery/tables.ts +5 -1
  23. package/src/delivery/types.ts +25 -2
  24. package/src/file-foundation/feature.ts +28 -141
  25. package/src/presets/__tests__/dsgvo-self-service.test.ts +35 -0
  26. package/src/presets/dsgvo-self-service.ts +30 -0
  27. package/src/presets/index.ts +1 -0
  28. package/src/secrets/feature.ts +3 -1
  29. package/src/secrets/index.ts +1 -0
  30. package/src/tags/constants.ts +6 -0
  31. package/src/tags/index.ts +1 -0
  32. package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
  33. package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
  34. package/src/tags/web/client-plugin.tsx +6 -0
  35. package/src/tags/web/i18n.ts +2 -2
  36. package/src/tags/web/index.ts +2 -0
  37. package/src/tags/web/tag-filter.tsx +24 -12
  38. package/src/tags/web/tags-cell.tsx +46 -0
  39. package/src/text-content/__tests__/seed-legal-content.integration.test.ts +61 -0
  40. package/src/text-content/seeding.ts +35 -1
  41. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
  42. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
  43. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
  44. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +7 -7
  45. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +7 -13
@@ -1,10 +1,13 @@
1
1
  // @runtime client
2
2
  // TagFilter — a drop-in tag filter for ANY entityList toolbar. Register it as
3
- // the list's header slot (`screen.slots.header`) and mark `id` filterable is NOT
4
- // needed the renderer passes this control the list's screenId, and picking
5
- // tags resolves the matching row ids and narrows the list via an id-set URL
6
- // filter (`<screenId>.f.id=…`, applied as `{ field: "id", op: "in" }`). No host
7
- // schema change: any list gets tag-filtering by mounting this in its header.
3
+ // the list's header slot (`screen.slots.header`); the renderer passes it the
4
+ // list's screenId, and picking tags resolves the matching row ids and narrows
5
+ // the list via an id-set URL filter (`<screenId>.f.id=…`, applied as
6
+ // `{ field: "id", op: "in" }`). No host schema change: any list gets
7
+ // tag-filtering by mounting this in its header.
8
+ //
9
+ // The active selection is shown inline as colored chips next to the button, with
10
+ // a clear affordance — so the filter is visible, not hidden behind a count.
8
11
  //
9
12
  // ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
10
13
  // URL filter — fine for typical tag volumes. For huge assignment sets add a
@@ -18,6 +21,7 @@ import {
18
21
  } from "@cosmicdrift/kumiko-renderer";
19
22
  import { type ReactNode, useState } from "react";
20
23
  import { TagsQueries } from "../constants";
24
+ import { TagChip } from "./tag-chip";
21
25
  import { TagPicker } from "./tag-picker";
22
26
 
23
27
  // Tags chosen but zero matching entities → filter to a value that matches no row
@@ -29,6 +33,7 @@ type AssignmentRow = {
29
33
  readonly entityType: string;
30
34
  readonly entityId: string;
31
35
  };
36
+ type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
32
37
 
33
38
  export function TagFilter({
34
39
  entityName,
@@ -42,6 +47,7 @@ export function TagFilter({
42
47
  const t = useTranslation();
43
48
  const [open, setOpen] = useState(false);
44
49
  const [selected, setSelected] = useState<readonly string[]>([]);
50
+ const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
45
51
  const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
46
52
  limit: 500,
47
53
  });
@@ -66,16 +72,22 @@ export function TagFilter({
66
72
  urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
67
73
  };
68
74
 
69
- const label =
70
- selected.length > 0
71
- ? t("tags.filter.active", { count: selected.length })
72
- : t("tags.filter.label");
75
+ const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
73
76
 
74
77
  return (
75
- <>
78
+ <div className="flex flex-wrap items-center gap-1">
76
79
  <Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
77
- {label}
80
+ {t("tags.filter.label")}
78
81
  </Button>
82
+ {selected.map((id) => {
83
+ const tag = byId.get(id);
84
+ return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
85
+ })}
86
+ {selected.length > 0 && (
87
+ <Button variant="secondary" onClick={() => applyFilter([])} testId="tag-filter-clear">
88
+ {t("tags.filter.clear")}
89
+ </Button>
90
+ )}
79
91
  <TagPicker
80
92
  entityType={entityName}
81
93
  value={selected}
@@ -83,6 +95,6 @@ export function TagFilter({
83
95
  open={open}
84
96
  onOpenChange={setOpen}
85
97
  />
86
- </>
98
+ </div>
87
99
  );
88
100
  }
@@ -0,0 +1,46 @@
1
+ // @runtime client
2
+ // TagsCell — a reusable columnRenderer that shows an entity's tags as colored
3
+ // chips inline in a list row. Drop it into ANY entityList by declaring a labeled
4
+ // virtual column (no host-schema change), after mounting tagsClient():
5
+ // columns: [
6
+ // "title",
7
+ // { field: "tags", label: "Tags",
8
+ // renderer: { react: { __component: TAGS_COLUMN_RENDERER_NAME } } },
9
+ // ]
10
+ // It reads the row's id (entity ids are unique, so entityType is unnecessary)
11
+ // and renders a TagChip per assigned tag; renders nothing when the row has none.
12
+ //
13
+ // ponytail: bulk-loads the assignment list (first 500) + the catalog once and
14
+ // filters per row — kumiko's useQuery dedupes the identical (type, payload)
15
+ // calls, so the whole column shares two queries, not two-per-row. For huge
16
+ // assignment sets add a server-side entitiesByTag query + join filter.
17
+
18
+ import { type ColumnRendererProps, useQuery } from "@cosmicdrift/kumiko-renderer";
19
+ import type { ReactNode } from "react";
20
+ import { TagsQueries } from "../constants";
21
+ import { TagChip } from "./tag-chip";
22
+
23
+ type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
24
+ type AssignmentRow = { readonly tagId: string; readonly entityId: string };
25
+
26
+ export function TagsCell({ row }: ColumnRendererProps): ReactNode {
27
+ const entityId = String(row["id"] ?? "");
28
+ const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
29
+ const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
30
+ limit: 500,
31
+ });
32
+ if (entityId === "") return null;
33
+ const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
34
+ const tagIds = (assignments.data?.rows ?? [])
35
+ .filter((a) => a.entityId === entityId)
36
+ .map((a) => a.tagId);
37
+ if (tagIds.length === 0) return null;
38
+ return (
39
+ <div className="flex flex-wrap gap-1" data-testid="tags-cell">
40
+ {tagIds.map((id) => {
41
+ const tag = byId.get(id);
42
+ return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
43
+ })}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,61 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { type DbConnection, fetchOne } from "@cosmicdrift/kumiko-framework/db";
3
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import {
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { createTextContentFeature } from "../feature";
11
+ import { type LegalContentBlock, seedLegalContentFromJson } from "../seeding";
12
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
13
+
14
+ // Pins seedLegalContentFromJson: seeds into SYSTEM_TENANT_ID by default and
15
+ // re-seeds with ifExists:"update" so a changed template body lands on an
16
+ // already-seeded block (legal-drift guard — the load-bearing behaviour).
17
+
18
+ let stack: TestStack;
19
+ let db: DbConnection;
20
+
21
+ beforeAll(async () => {
22
+ stack = await setupTestStack({ features: [createTextContentFeature()] });
23
+ db = stack.db;
24
+ await unsafeCreateEntityTable(db, textBlockEntity);
25
+ await createEventsTable(db);
26
+ });
27
+
28
+ afterAll(async () => {
29
+ await stack.cleanup();
30
+ });
31
+
32
+ function read(slug: string, lang: string) {
33
+ return fetchOne<TextBlockRow>(db, textBlocksTable, { tenantId: SYSTEM_TENANT_ID, slug, lang });
34
+ }
35
+
36
+ describe("seedLegalContentFromJson", () => {
37
+ test("seeds all blocks into SYSTEM_TENANT_ID by default", async () => {
38
+ const blocks: LegalContentBlock[] = [
39
+ { slug: "imprint", lang: "de", title: "Impressum", body: "Marc Frost" },
40
+ { slug: "imprint", lang: "en", title: "Imprint", body: "Marc Frost" },
41
+ ];
42
+ await seedLegalContentFromJson(db, blocks);
43
+
44
+ expect(await read("imprint", "de")).toMatchObject({ title: "Impressum", body: "Marc Frost" });
45
+ expect(await read("imprint", "en")).toMatchObject({ title: "Imprint", body: "Marc Frost" });
46
+ });
47
+
48
+ test("re-seed lifts an existing block to the new template state (ifExists:update)", async () => {
49
+ const v1: LegalContentBlock[] = [
50
+ { slug: "privacy", lang: "de", title: "Datenschutz", body: "v1" },
51
+ ];
52
+ await seedLegalContentFromJson(db, v1);
53
+ expect(await read("privacy", "de")).toMatchObject({ body: "v1" });
54
+
55
+ const v2: LegalContentBlock[] = [
56
+ { slug: "privacy", lang: "de", title: "Datenschutz", body: "v2 + Sub-Processor-Tabelle" },
57
+ ];
58
+ await seedLegalContentFromJson(db, v2);
59
+ expect(await read("privacy", "de")).toMatchObject({ body: "v2 + Sub-Processor-Tabelle" });
60
+ });
61
+ });
@@ -9,7 +9,11 @@ import {
9
9
  createTenantDb,
10
10
  type DbConnection,
11
11
  } from "@cosmicdrift/kumiko-framework/db";
12
- import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ type SessionUser,
14
+ SYSTEM_TENANT_ID,
15
+ type TenantId,
16
+ } from "@cosmicdrift/kumiko-framework/engine";
13
17
  import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
14
18
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
15
19
  import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
@@ -101,3 +105,33 @@ export async function seedTextBlock(
101
105
  },
102
106
  });
103
107
  }
108
+
109
+ export type LegalContentBlock = {
110
+ readonly slug: string;
111
+ readonly lang: string;
112
+ readonly title: string;
113
+ readonly body: string;
114
+ };
115
+
116
+ // Boot-Seed für Legal-/Marketing-Texte aus einer JSON-Vorlage. `ifExists:
117
+ // "update"` ist load-bearing: die Vorlage ist autoritativ, jeder Boot hebt
118
+ // bestehende Blöcke auf den kompilierten Stand — sonst erreichen gesetzlich
119
+ // vorgeschriebene Zusätze (z.B. Sub-Processor-Tabelle) bereits geseedete
120
+ // Prod-Records nie. Genau diese Entscheidung soll keine App selbst treffen.
121
+ export async function seedLegalContentFromJson(
122
+ db: DbConnection,
123
+ blocks: readonly LegalContentBlock[],
124
+ opts: { readonly tenantId?: TenantId } = {},
125
+ ): Promise<void> {
126
+ const tenantId = opts.tenantId ?? SYSTEM_TENANT_ID;
127
+ for (const block of blocks) {
128
+ await seedTextBlock(db, {
129
+ tenantId,
130
+ slug: block.slug,
131
+ lang: block.lang,
132
+ title: block.title,
133
+ body: block.body,
134
+ ifExists: "update",
135
+ });
136
+ }
137
+ }
@@ -80,7 +80,6 @@ beforeAll(async () => {
80
80
  createUserDataRightsFeature(),
81
81
  createUserDataRightsDefaultsFeature(),
82
82
  ],
83
- files: { storageProvider: provider },
84
83
  extraContext: ({ registry }) => ({
85
84
  configResolver: resolver,
86
85
  _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
@@ -90,7 +90,6 @@ beforeAll(async () => {
90
90
  createUserDataRightsFeature(),
91
91
  createUserDataRightsDefaultsFeature(),
92
92
  ],
93
- files: { storageProvider: flakyProvider },
94
93
  extraContext: ({ registry }) => ({
95
94
  configResolver: resolver,
96
95
  _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
@@ -0,0 +1,162 @@
1
+ // #608 trap-closed regression: uploads and erasure resolve the file provider
2
+ // through ONE source (file-foundation), end-to-end through the real server.
3
+ //
4
+ // The stack mounts file-foundation + a provider plugin but does NOT inject a
5
+ // resolver (no `files:` option) — so buildServer must build the upload-route
6
+ // resolver itself from the mounted features, exactly as production does. A file
7
+ // uploaded through POST /api/files must land in the SAME store the GDPR forget
8
+ // pipeline deletes from. Before the unification the upload route wrote through a
9
+ // separately-wired provider while erasure deleted through file-foundation — so a
10
+ // regression to a static `storageProvider` makes the `provider.exists(...)`
11
+ // assertion below go red.
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
15
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
16
+ import { type SessionUser, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
18
+ import {
19
+ createInMemoryFileProvider,
20
+ type FileStorageProvider,
21
+ fileRefsTable,
22
+ type InMemoryFileProvider,
23
+ } from "@cosmicdrift/kumiko-framework/files";
24
+ import {
25
+ setupTestStack,
26
+ type TestStack,
27
+ unsafeCreateEntityTable,
28
+ unsafePushTables,
29
+ } from "@cosmicdrift/kumiko-framework/stack";
30
+ import {
31
+ buildMultipartBody,
32
+ patchFileInstanceofForBunTest,
33
+ resetTestTables,
34
+ } from "@cosmicdrift/kumiko-framework/testing";
35
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
36
+ import { createConfigFeature } from "../../config";
37
+ import { createConfigAccessorFactory } from "../../config/feature";
38
+ import { createConfigResolver } from "../../config/resolver";
39
+ import { configValuesTable } from "../../config/table";
40
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
41
+ import { fileFoundationFeature } from "../../file-foundation";
42
+ import { createFilesFeature } from "../../files";
43
+ import { createSessionsFeature } from "../../sessions";
44
+ import { createUserFeature, userEntity, userTable } from "../../user";
45
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
46
+ import { createUserDataRightsFeature } from "../feature";
47
+ import { makeTenantStorageProviderResolver } from "../lib/storage-provider-resolver";
48
+ import { runForgetCleanup } from "../run-forget-cleanup";
49
+ import {
50
+ createForgetSeeders,
51
+ createTestFileProviderFeature,
52
+ type ForgetSeeders,
53
+ nowInstant,
54
+ READ_TENANT_MEMBERSHIPS_DDL,
55
+ TENANT_SYSTEM,
56
+ } from "./forget-test-helpers";
57
+
58
+ const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider";
59
+
60
+ let stack: TestStack;
61
+ let db: DbConnection;
62
+ let provider: InMemoryFileProvider;
63
+ let seed: ForgetSeeders;
64
+ let buildStorageProvider: (tenantId: string) => Promise<FileStorageProvider>;
65
+
66
+ function uuid(suffix: number): string {
67
+ return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
68
+ }
69
+
70
+ async function uploadAs(user: SessionUser, fileName: string, bytes: Uint8Array): Promise<Response> {
71
+ const token = await stack.jwt.sign(user);
72
+ const fd = new FormData();
73
+ fd.append("file", new File([Buffer.from(bytes)], fileName, { type: "application/pdf" }));
74
+ const { body, contentType } = await buildMultipartBody(fd);
75
+ return stack.app.request("/api/files", {
76
+ method: "POST",
77
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
78
+ body,
79
+ });
80
+ }
81
+
82
+ beforeAll(async () => {
83
+ patchFileInstanceofForBunTest();
84
+ provider = createInMemoryFileProvider();
85
+ // Select the test plugin app-wide via a config app-override (no admin write).
86
+ const appOverrides = new Map<string, string>([[FILE_PROVIDER_CONFIG_KEY, "test"]]);
87
+ const resolver = createConfigResolver({ appOverrides });
88
+ stack = await setupTestStack({
89
+ features: [
90
+ createConfigFeature(),
91
+ createUserFeature(),
92
+ createFilesFeature(),
93
+ fileFoundationFeature,
94
+ createTestFileProviderFeature(provider, "test"),
95
+ createDataRetentionFeature(),
96
+ createComplianceProfilesFeature(),
97
+ createSessionsFeature(),
98
+ createUserDataRightsFeature(),
99
+ createUserDataRightsDefaultsFeature(),
100
+ ],
101
+ // No `files:` option on purpose — buildServer must resolve the upload
102
+ // provider through the mounted file-foundation, like production.
103
+ extraContext: ({ registry }) => ({
104
+ configResolver: resolver,
105
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
106
+ }),
107
+ });
108
+ db = stack.db;
109
+ seed = createForgetSeeders(db, provider);
110
+ buildStorageProvider = makeTenantStorageProviderResolver({
111
+ registry: stack.registry,
112
+ configResolver: resolver,
113
+ secrets: undefined,
114
+ db,
115
+ userId: SYSTEM_USER_ID,
116
+ handlerName: "test-608-e2e",
117
+ });
118
+
119
+ await unsafeCreateEntityTable(db, userEntity);
120
+ await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
121
+ await unsafePushTables(db, { fileRefsTable, configValuesTable });
122
+ await createEventsTable(db);
123
+ await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
124
+ });
125
+
126
+ afterAll(async () => {
127
+ await stack.cleanup();
128
+ });
129
+
130
+ beforeEach(async () => {
131
+ provider.clear();
132
+ await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
133
+ });
134
+
135
+ describe("#608 unified file-storage :: route upload + GDPR erasure hit one store", () => {
136
+ test("a file uploaded via /api/files lands in the file-foundation store and forget erases it", async () => {
137
+ const userId = uuid(1);
138
+ // The uploader is also the forget subject (DeletionRequested + grace passed).
139
+ await seed.seedForgetUser(userId);
140
+ await seed.seedMembership(userId, TENANT_SYSTEM);
141
+ const uploader: SessionUser = { id: userId, tenantId: TENANT_SYSTEM, roles: ["Member"] };
142
+
143
+ const res = await uploadAs(uploader, "secret.pdf", new Uint8Array([10, 20, 30, 40]));
144
+ expect(res.status).toBe(201);
145
+ const { storageKey } = (await res.json()) as { storageKey: string };
146
+
147
+ // The upload route resolved its provider through file-foundation — the bytes
148
+ // are in `provider`. A static/separate upload provider would miss here.
149
+ expect(await provider.exists(storageKey)).toBe(true);
150
+
151
+ // Erasure resolves through the SAME source and deletes the very same bytes —
152
+ // no false "done", no orphaned binary.
153
+ const result = await runForgetCleanup({
154
+ db,
155
+ registry: stack.registry,
156
+ now: nowInstant(),
157
+ buildStorageProvider,
158
+ });
159
+ expect(result.processedUserIds).toContain(userId);
160
+ expect(await provider.exists(storageKey)).toBe(false);
161
+ });
162
+ });
@@ -51,12 +51,12 @@ function renderWith(ui: ReactElement, dispatcher: Dispatcher): ReturnType<typeof
51
51
  return within(container);
52
52
  }
53
53
 
54
- // SKIPPED CI-only flake (#457): on the shared single-process happy-dom runner
55
- // the click never reaches React's submit handler after ~30 prior DOM test files
56
- // have mounted/unmounted (write is never invoked, calls stays empty). Green
57
- // locally and on main; not a timing issue. Un-skip once the global afterEach
58
- // teardown / per-file DOM isolation fix lands.
59
- describe.skip("RequestAccountDeletionScreen", () => {
54
+ // CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
55
+ // shared `kumiko check` run see bunfig.ci.toml pathIgnorePatterns. The shared
56
+ // single-process happy-dom corrupts React event delegation after ~30 prior DOM
57
+ // test files mount/unmount, so the click never reached the submit handler here
58
+ // (#457). A fresh process has no such accumulation.
59
+ describe("RequestAccountDeletionScreen", () => {
60
60
  test("Submit → write(request-deletion-by-email) + enumeration-safe Success", async () => {
61
61
  const calls: WriteCall[] = [];
62
62
  const ui = renderWith(<RequestAccountDeletionScreen />, makeDispatcher(true, calls));
@@ -78,7 +78,7 @@ describe.skip("RequestAccountDeletionScreen", () => {
78
78
  });
79
79
  });
80
80
 
81
- describe.skip("ConfirmAccountDeletionScreen", () => {
81
+ describe("ConfirmAccountDeletionScreen", () => {
82
82
  test("ohne ?token → missingToken, kein Confirm-Button", () => {
83
83
  window.history.replaceState({}, "", "/delete-account/confirm");
84
84
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
@@ -114,19 +114,13 @@ async function waitForMount(view: ReturnType<typeof render>): Promise<void> {
114
114
  });
115
115
  }
116
116
 
117
- // QUARANTINED (#457-Klasse): diese Render-Tests laufen lokal grün (13/13 isoliert,
118
- // 82/88 mit allen bundled-features-web-Tests parallel), failen aber auf CI-Linux
119
- // unter bun-`concurrency=8`. Diagnose aus dem CI-Log: parallele Test-FILES teilen
120
- // sich das eine globale happy-dom `document`; der globale `afterEach` aus
121
- // `test-setup/dom.preload.ts` (`cleanup()` + `document.body.replaceChildren()`)
122
- // eines parallel laufenden Tests wischt die in-flight gerenderte DOM eines anderen
123
- // weg die `await`-Assertions hier finden den Screen-Stand eines Nachbar-Tests
124
- // (sichtbar: alle Fails zeigen denselben active-state Screen statt der eigenen
125
- // Test-Daten). Nicht aus diesem File fixbar — gleiche Architektur-Flake, wegen der
126
- // `deletion-screens.test.tsx` im selben Verzeichnis quarantäniert ist. Un-skip,
127
- // sobald das framework-weite Test-Isolation-Problem (#457) gelöst ist. Die
128
- // QN-Drift-Pins + formatDate unten decken die CI-stabile Verdrahtungs-Korrektheit ab.
129
- describe.skip("PrivacyCenterScreen", () => {
117
+ // CI runs this file in its own `bun test` process (own ci.yml step), NOT in the
118
+ // shared `kumiko check` run see bunfig.ci.toml pathIgnorePatterns. In the shared
119
+ // single-process happy-dom, the global `afterEach` from `test-setup/dom.preload.ts`
120
+ // plus accumulated global DOM/event state across ~30 prior DOM test files corrupts
121
+ // these in-flight renders (#457-class). A fresh process has no such accumulation.
122
+ // The QN-Drift-Pins + formatDate describes below are pure-logic and CI-stable.
123
+ describe("PrivacyCenterScreen", () => {
130
124
  test("aktiver User: Export/Einschränken/Löschen-Sektionen, Texte übersetzt (keine rohen Keys)", async () => {
131
125
  const { view } = renderCenter({ me: activeMe });
132
126
  await waitForMount(view);