|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import { fileURLToPath } from 'node:url'; |
| 4 | +import type { Page } from '@playwright/test'; |
| 5 | +import type { StoryLocator } from '@superdoc/document-api'; |
| 6 | +import { test, expect } from '../../fixtures/superdoc.js'; |
| 7 | +import { assertDocumentApiReady } from '../../helpers/document-api.js'; |
| 8 | + |
| 9 | +const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
| 10 | +const HEADER_DOC_PATH = path.resolve( |
| 11 | + __dirname, |
| 12 | + '../../../../packages/super-editor/src/editors/v1/tests/data/longer-header-sign-area.docx', |
| 13 | +); |
| 14 | + |
| 15 | +test.skip(!fs.existsSync(HEADER_DOC_PATH), 'Test document not available'); |
| 16 | + |
| 17 | +/** |
| 18 | + * Resolves a header story locator and a text range within it that can be used |
| 19 | + * as an insertion target for bookmarks. |
| 20 | + */ |
| 21 | +async function resolveHeaderInsertionTarget(page: Page) { |
| 22 | + return page.evaluate(() => { |
| 23 | + const docApi = (window as any).editor?.doc; |
| 24 | + if (!docApi?.headerFooters?.list || !docApi?.find || !docApi?.bookmarks) { |
| 25 | + throw new Error('Required document APIs are unavailable.'); |
| 26 | + } |
| 27 | + |
| 28 | + const headers = docApi.headerFooters.list({ kind: 'header' }); |
| 29 | + const entry = headers?.items?.find((item: any) => item?.variant === 'default') ?? headers?.items?.[0]; |
| 30 | + if (!entry?.section?.sectionId) { |
| 31 | + throw new Error('Unable to resolve a header/footer slot for the test document.'); |
| 32 | + } |
| 33 | + |
| 34 | + const story: StoryLocator = { |
| 35 | + kind: 'story', |
| 36 | + storyType: 'headerFooterSlot', |
| 37 | + section: entry.section, |
| 38 | + headerFooterKind: 'header', |
| 39 | + variant: entry.variant ?? 'default', |
| 40 | + } as any; |
| 41 | + |
| 42 | + const toRanges = (item: any) => { |
| 43 | + const blocks = Array.isArray(item?.blocks) ? item.blocks : []; |
| 44 | + return blocks |
| 45 | + .map((block: any) => { |
| 46 | + const blockId = block?.blockId; |
| 47 | + const start = block?.range?.start; |
| 48 | + const end = block?.range?.end; |
| 49 | + if (typeof blockId !== 'string' || typeof start !== 'number' || typeof end !== 'number') return null; |
| 50 | + return { kind: 'text' as const, blockId, range: { start, end } }; |
| 51 | + }) |
| 52 | + .filter(Boolean); |
| 53 | + }; |
| 54 | + |
| 55 | + const queryMatch = docApi?.query?.match; |
| 56 | + const queryResult = |
| 57 | + typeof queryMatch === 'function' |
| 58 | + ? queryMatch({ |
| 59 | + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, |
| 60 | + require: 'any', |
| 61 | + in: story, |
| 62 | + }) |
| 63 | + : null; |
| 64 | + |
| 65 | + const queryItem = Array.isArray(queryResult?.items) ? queryResult.items[0] : null; |
| 66 | + const findResult = |
| 67 | + queryItem == null |
| 68 | + ? docApi.find({ |
| 69 | + select: { type: 'text', pattern: 'Generic content header', mode: 'contains' }, |
| 70 | + in: story, |
| 71 | + limit: 1, |
| 72 | + }) |
| 73 | + : null; |
| 74 | + const firstItem = queryItem ?? (Array.isArray(findResult?.items) ? findResult.items[0] : null); |
| 75 | + const textRange = toRanges(firstItem)[0] ?? null; |
| 76 | + |
| 77 | + if (!textRange) { |
| 78 | + throw new Error('Unable to resolve a header text range for bookmark insertion.'); |
| 79 | + } |
| 80 | + |
| 81 | + return { story, textRange }; |
| 82 | + }); |
| 83 | +} |
| 84 | + |
| 85 | +test.describe('Header bookmark CRUD', () => { |
| 86 | + test('@behavior insert, get, list, rename, and remove a bookmark in a header story', async ({ superdoc }) => { |
| 87 | + await superdoc.loadDocument(HEADER_DOC_PATH); |
| 88 | + await superdoc.waitForStable(2000); |
| 89 | + await assertDocumentApiReady(superdoc.page); |
| 90 | + |
| 91 | + const { story, textRange } = await resolveHeaderInsertionTarget(superdoc.page); |
| 92 | + const bookmarkName = `hf-crud-${Date.now()}`; |
| 93 | + |
| 94 | + // --- Insert --- |
| 95 | + const insertResult = await superdoc.page.evaluate( |
| 96 | + ({ name, at, storyLocator }) => { |
| 97 | + return (window as any).editor.doc.bookmarks.insert({ |
| 98 | + name, |
| 99 | + at: { kind: 'text', segments: [at], story: storyLocator }, |
| 100 | + }); |
| 101 | + }, |
| 102 | + { name: bookmarkName, at: textRange, storyLocator: story }, |
| 103 | + ); |
| 104 | + |
| 105 | + expect(insertResult.success).toBe(true); |
| 106 | + expect(insertResult.bookmark).toEqual( |
| 107 | + expect.objectContaining({ |
| 108 | + kind: 'entity', |
| 109 | + entityType: 'bookmark', |
| 110 | + name: bookmarkName, |
| 111 | + story: expect.objectContaining({ storyType: 'headerFooterSlot' }), |
| 112 | + }), |
| 113 | + ); |
| 114 | + |
| 115 | + // --- Get --- |
| 116 | + const getResult = await superdoc.page.evaluate( |
| 117 | + ({ name, storyLocator }) => { |
| 118 | + return (window as any).editor.doc.bookmarks.get({ |
| 119 | + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, |
| 120 | + }); |
| 121 | + }, |
| 122 | + { name: bookmarkName, storyLocator: story }, |
| 123 | + ); |
| 124 | + |
| 125 | + expect(getResult).toEqual( |
| 126 | + expect.objectContaining({ |
| 127 | + name: bookmarkName, |
| 128 | + address: expect.objectContaining({ |
| 129 | + kind: 'entity', |
| 130 | + entityType: 'bookmark', |
| 131 | + name: bookmarkName, |
| 132 | + }), |
| 133 | + }), |
| 134 | + ); |
| 135 | + |
| 136 | + // --- List (filtered to header story) --- |
| 137 | + const listResult = await superdoc.page.evaluate( |
| 138 | + ({ storyLocator }) => { |
| 139 | + return (window as any).editor.doc.bookmarks.list({ in: storyLocator }); |
| 140 | + }, |
| 141 | + { storyLocator: story }, |
| 142 | + ); |
| 143 | + |
| 144 | + const listedNames: string[] = (listResult?.items ?? []).map((item: any) => item?.name ?? item?.address?.name); |
| 145 | + expect(listedNames).toContain(bookmarkName); |
| 146 | + |
| 147 | + // --- Rename --- |
| 148 | + const renamedName = `${bookmarkName}-renamed`; |
| 149 | + const renameResult = await superdoc.page.evaluate( |
| 150 | + ({ oldName, newName, storyLocator }) => { |
| 151 | + return (window as any).editor.doc.bookmarks.rename({ |
| 152 | + target: { kind: 'entity', entityType: 'bookmark', name: oldName, story: storyLocator }, |
| 153 | + newName, |
| 154 | + }); |
| 155 | + }, |
| 156 | + { oldName: bookmarkName, newName: renamedName, storyLocator: story }, |
| 157 | + ); |
| 158 | + |
| 159 | + expect(renameResult.success).toBe(true); |
| 160 | + expect(renameResult.bookmark).toEqual( |
| 161 | + expect.objectContaining({ |
| 162 | + name: renamedName, |
| 163 | + story: expect.objectContaining({ storyType: 'headerFooterSlot' }), |
| 164 | + }), |
| 165 | + ); |
| 166 | + |
| 167 | + // --- Get after rename --- |
| 168 | + const getAfterRename = await superdoc.page.evaluate( |
| 169 | + ({ name, storyLocator }) => { |
| 170 | + return (window as any).editor.doc.bookmarks.get({ |
| 171 | + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, |
| 172 | + }); |
| 173 | + }, |
| 174 | + { name: renamedName, storyLocator: story }, |
| 175 | + ); |
| 176 | + |
| 177 | + expect(getAfterRename).toEqual(expect.objectContaining({ name: renamedName })); |
| 178 | + |
| 179 | + // --- Remove --- |
| 180 | + const removeResult = await superdoc.page.evaluate( |
| 181 | + ({ name, storyLocator }) => { |
| 182 | + return (window as any).editor.doc.bookmarks.remove({ |
| 183 | + target: { kind: 'entity', entityType: 'bookmark', name, story: storyLocator }, |
| 184 | + }); |
| 185 | + }, |
| 186 | + { name: renamedName, storyLocator: story }, |
| 187 | + ); |
| 188 | + |
| 189 | + expect(removeResult.success).toBe(true); |
| 190 | + |
| 191 | + // --- List after remove (bookmark should be gone) --- |
| 192 | + const listAfterRemove = await superdoc.page.evaluate( |
| 193 | + ({ storyLocator }) => { |
| 194 | + return (window as any).editor.doc.bookmarks.list({ in: storyLocator }); |
| 195 | + }, |
| 196 | + { storyLocator: story }, |
| 197 | + ); |
| 198 | + |
| 199 | + const remainingNames: string[] = (listAfterRemove?.items ?? []).map( |
| 200 | + (item: any) => item?.name ?? item?.address?.name, |
| 201 | + ); |
| 202 | + expect(remainingNames).not.toContain(renamedName); |
| 203 | + }); |
| 204 | +}); |
0 commit comments