Skip to content
Draft
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
99 changes: 99 additions & 0 deletions tests/sanity/tests/card/copy-as-markdown-table.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test'
import { generateId, PlatformSetting, PlatformURI } from '../utils'
import { CardsPage } from '../model/card/cards-page'
import { CardContentPage } from '../model/card/card-content-page'

test.use({
storageState: PlatformSetting
})

test.describe('Copy as Markdown Table tests', () => {
let cardsPage: CardsPage
let cardContentPage: CardContentPage

test.beforeEach(async ({ page }) => {
cardsPage = new CardsPage(page)
cardContentPage = new CardContentPage(page)

await page.goto(`${PlatformURI}/workbench/sanity-ws`)
await cardsPage.clickCardApp()
await cardsPage.clickAllCards()
})

test('Copy as Markdown Table pastes as a real table, not raw markdown text', async ({ page, context }) => {
// playwright.config.ts already grants these globally for the "Platform" project, but the
// grant is re-asserted here since this spec is the first to rely on a full clipboard
// write+read+paste round trip rather than just navigator.clipboard.readText().
await context.grantPermissions(['clipboard-read', 'clipboard-write'])

const spaceName = `Markdown Table Space-${generateId()}`
const sourceCardTitle = `Copy Table Card-${generateId()}`
const targetCardTitle = `Paste Table Card-${generateId()}`

await test.step('Create a card space and two cards', async () => {
// A fresh sanity workspace does not seed any card.class.CardSpace, and the "Create Card"
// dialog needs a valid space bound before its "Create" button is enabled, so create one
// up front and reuse it for both cards.
await cardsPage.createCardSpace(spaceName)

await cardsPage.createCard(sourceCardTitle)
await cardContentPage.checkCardTitle(sourceCardTitle)

await cardsPage.clickAllCards()
await cardsPage.createCard(targetCardTitle)
await cardContentPage.checkCardTitle(targetCardTitle)

await cardsPage.clickAllCards()
})

await test.step('Copy the source card as a Markdown table', async () => {
await cardsPage.doActionOnCard(sourceCardTitle, 'Copy as Markdown Table')

// Sanity check of the *copy* side, independent of the paste side: copyMarkdown() in
// plugins/view-resources/src/actionImpl.ts only ever writes a "text/plain" clipboard
// entry (browsers reject "text/markdown"/custom MIME types in ClipboardItem), containing
// the markdown table plus a trailing metadata HTML comment. If the copy action stopped
// producing a real markdown table, the paste-side assertions below would be meaningless,
// so this is checked first and on its own.
const clipboardText = await page.evaluate(async () => await navigator.clipboard.readText())
expect(clipboardText).toContain(sourceCardTitle)
expect(clipboardText).toContain('|')
expect(clipboardText).toContain('---')
})

await test.step('Paste the copied table into another card and verify it renders as a real table', async () => {
await cardsPage.clickAllCards()
await cardsPage.openCard(targetCardTitle)
await cardContentPage.checkCardTitle(targetCardTitle)

await cardContentPage.focusContentEditor()
await cardContentPage.pasteFromClipboard()

// REGRESSION GUARD - this is the entire point of the test.
//
// plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts
// intercepts the paste, converts the clipboard's markdown to a ProseMirror document via
// markdownToMarkup(), and only replaces the browser's default paste with that real node
// when shouldUseMarkdownOutput() finds an "important" node type - "table" is one of the
// importantMarkupNodeTypes. If that detection, or the markdown -> markup conversion of
// the table itself, ever regresses, handlePaste() returns false and the browser falls
// back to inserting the clipboard's raw "text/plain" payload as an ordinary paragraph:
// the pipe-delimited "| Header | ... |" / "| --- | ... |" markdown lines would show up
// as literal, unrendered text instead of an actual <table>.
//
// Positive assertion: a real <table class="proseTable"> appeared, and one of its cells
// contains the source card's title (proving the pasted content is the table we copied,
// not some unrelated fallback content).
await expect(cardContentPage.proseTable()).toBeVisible({ timeout: 15000 })
await expect(cardContentPage.proseTable().locator('td', { hasText: sourceCardTitle }).first()).toBeVisible()

// Negative assertion: this is what the regression looks like in the DOM - the table
// markdown (header/separator row, e.g. "| --- | --- |") and the metadata HTML comment
// rendered as plain, literal text content inside the editor instead of being parsed away
// into real table/comment-free nodes.
const editorText = (await cardContentPage.contentEditor().textContent()) ?? ''
expect(editorText).not.toMatch(/\|\s*-{3,}\s*\|/)
expect(editorText).not.toContain('<!--')
})
})
})
57 changes: 57 additions & 0 deletions tests/sanity/tests/model/card/card-content-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type Locator, type Page, expect } from '@playwright/test'
import { CommonPage } from '../common-page'

export class CardContentPage extends CommonPage {
readonly page: Page

constructor (page: Page) {
super(page)
this.page = page
}

// The card title is edited via an EditBox rendered in slot="title" of EditCardNew.svelte,
// which Panel places inside the header element with class "hulyHeader-titleGroup" (see
// EditCardNew.svelte's afterUpdate(), which queries `element.querySelector('.hulyHeader-
// titleGroup')` for that same element). Unlike Documents (whose title lives inside
// "div[class*='main-content']"), Card's ".main-content" div only wraps the card body/content
// editor (EditCardNewContent), not the title.
readonly inputCardTitle = (): Locator => this.page.locator('.hulyHeader-titleGroup div.title input')

// The card's main "content" editor (Description.svelte -> ContentEditor.svelte ->
// CollaboratorEditor -> CollaborativeTextEditor), scoped to two things:
// - ".main-content" (EditCardNew.svelte), the card body wrapper - both the content editor
// and the comments editor (MessageInput, rendered in a sibling ".message-input" div by
// EditCardNewContent.svelte) live inside it, so this alone would not disambiguate them.
// - ".textInput" (CollaborativeTextEditor.svelte), which is only rendered by the
// Collaborator/CollaborativeTextEditor stack used for the card's content attribute.
// MessageInput instead renders communication-resources/TextInput.svelte, which uses the
// plain text-editor-resources TextEditor.svelte - that component does NOT render a
// ".textInput" wrapper (only a bare ".select-text" div), so this selector cannot
// accidentally match the comments box.
readonly contentEditor = (): Locator => this.page.locator('.main-content .textInput div.tiptap')

// All rich-text editors in this app share the same Tiptap kit (text-editor-resources/src/
// kits/editor-kit.ts), which renders <table> nodes with HTMLAttributes class "proseTable" -
// this mirrors the existing convention in
// tests/sanity/tests/model/documents/document-content-page.ts (proseTableCell), so it is not
// Card-specific and stays valid even if Card's own DOM nesting changes.
readonly proseTable = (): Locator => this.contentEditor().locator('table.proseTable')

async checkCardTitle (title: string): Promise<void> {
await expect(this.inputCardTitle()).toHaveValue(title)
}

async focusContentEditor (): Promise<void> {
await this.contentEditor().click()
}

// Pastes whatever is currently on the (fake, per-context) clipboard. Chromium's async
// Clipboard API shares its per-context clipboard with native paste events, and
// tests/sanity/tests/playwright.config.ts already grants clipboard-read/clipboard-write
// permissions globally, so a real keyboard paste exercises the same handlePaste() code path
// (plugins/text-editor-resources/src/components/extension/shortcuts/smartPaste.ts) that a
// user's Ctrl/Cmd+V would.
async pasteFromClipboard (): Promise<void> {
await this.page.keyboard.press('ControlOrMeta+KeyV')
}
}
111 changes: 111 additions & 0 deletions tests/sanity/tests/model/card/cards-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type Locator, type Page, expect } from '@playwright/test'
import { CommonPage } from '../common-page'

export class CardsPage extends CommonPage {
readonly page: Page

constructor (page: Page) {
super(page)
this.page = page
}

readonly buttonCardApp = (): Locator => this.page.locator('button[id$="card:string:CardApplication"]')

// The "All Cards" special navigator item (models/card/src/index.ts, navigatorModel.specials,
// id: 'all'), rendered by the generic workbench Navigator.svelte the same way as every other
// app's specials (e.g. tracker's "My issues"), so a plain text lookup inside the navigator
// panel matches the existing convention (see IssuesPage.myIssuesButton).
readonly linkAllCards = (): Locator => this.page.locator('.antiPanel-navigator').locator('text="All Cards"')

// The "+" button in the Card app's navigator header (card.component.CardHeaderButton, wired
// via workbench.class.Application.navHeaderActions in models/card/src/index.ts). It has no
// dataId/id of its own, but it is the only <button> rendered inside the current app's
// ".hulyNavPanel-header" (see NavHeader.svelte), matched here through the existing
// CommonPage.appHeader() locator. Clicking it opens a Menu popup with "Create Card" and
// "Create Space" actions - there is no dedicated "New" button on the "All Cards" table view
// itself (SpecialView.svelte only renders one when createLabel/createComponent/createButton
// are set, which the "all" special does not do), so this is the only reliable entry point.
readonly buttonNavHeaderAdd = (): Locator => this.appHeader().locator('button')

// "Create Space" popup (CreateSpace.svelte, in components/navigator). It reuses the same
// "teamspace-title" id as the Documents teamspace-creation form.
readonly inputCardSpaceTitle = (): Locator => this.page.locator('div[id="teamspace-title"] input')

// "Create Card" popup title field. Depending on whether the target MasterTag has a
// registered card.mixin.CreateCardExtension, either CreateCardPopupSimple (wrapped in
// ".antiCard") or CreateCardPopupFull (wrapped in ".hulyModal-container") is shown; both
// render the same ModernEditbox with label view.string.Title ("Title") as a placeholder.
readonly inputCardTitle = (): Locator =>
this.page.locator('.antiCard input[placeholder="Title"], .hulyModal-container input[placeholder="Title"]')

// Submit button for both the "Create Space" and "Create Card" popups (Card.svelte /
// Modal.svelte both render a primary button whose visible label is the translated
// presentation.string.Create, "Create").
readonly buttonCreateSubmit = (): Locator => this.page.getByRole('button', { name: 'Create', exact: true })

// Row in the generic Table viewlet (view.viewlet.Table) used by the "All Cards" special
// view. Mirrors the existing convention in AllProjectsPage (tracker) - Table.svelte binds
// on:contextmenu directly on each "tr.antiTable-body__row".
readonly cardRow = (title: string): Locator =>
this.page.locator('.antiTable-body__row', { has: this.page.locator(`td:has-text("${title}")`) })

// The row's bulk-select checkbox (CheckBox.svelte renders "input.chBox"), inside
// ".antiTable-cells__checkCell". It is only "visibility: visible" on row :hover (see
// packages/theme/styles/components.scss), so callers must hover the row first.
readonly cardRowCheckbox = (title: string): Locator => this.cardRow(title).locator('input.chBox')

async clickCardApp (): Promise<void> {
await this.buttonCardApp().click()
}

async clickAllCards (): Promise<void> {
await this.linkAllCards().click()
}

// Creates a card.class.CardSpace. A freshly seeded sanity workspace does not provision any
// CardSpace, and the "Create Card" dialog's SpaceSelector needs a valid space bound before
// its "Create" button becomes enabled (SpaceSelect auto-selects the first matching space
// only once one exists), so tests must create one before creating any card.
async createCardSpace (name: string): Promise<void> {
await this.buttonNavHeaderAdd().click()
await this.selectFromDropdown(this.page, 'Create space')
await expect(this.inputCardSpaceTitle()).toBeVisible()
await this.inputCardSpaceTitle().fill(name)
await this.buttonCreateSubmit().click()
await expect(this.inputCardSpaceTitle()).not.toBeVisible()
}

// Creates a card via the navigator header "+" -> "Create Card" flow and waits for the
// resulting popup to close (CreateCardPopup dispatches 'close' with the new card id, which
// CardHeaderButton.svelte's handleCreateCard callback uses to navigate straight into the new
// card's edit panel).
async createCard (title: string): Promise<void> {
await this.buttonNavHeaderAdd().click()
await this.selectFromDropdown(this.page, 'Create Card')
await expect(this.inputCardTitle()).toBeVisible()
await this.inputCardTitle().fill(title)
await expect(this.buttonCreateSubmit()).toBeEnabled({ timeout: 15000 })
await this.buttonCreateSubmit().click()
await expect(this.inputCardTitle()).not.toBeVisible()
}

// Unlike IssuesPage.doActionOnIssue, this checks the row's bulk-select checkbox first.
// Table.svelte's showContextMenu() only passes an *array* of docs to the menu (`checked`)
// when at least one row is checked; otherwise it passes the single bare Doc. Actions
// registered with input: 'selection' (like "Copy as Markdown Table") are filtered out by
// filterAvailableActions() unless the doc passed in is an array
// (plugins/view-resources/src/actions.ts), so a plain right-click on an unchecked row will
// never show them - only input: 'focus'/'any' actions (like "Copy as Markdown") appear then.
async doActionOnCard (title: string, action: string): Promise<void> {
await this.cardRow(title).hover()
await this.cardRowCheckbox(title).click()
await this.cardRow(title).click({ button: 'right' })
await this.selectFromDropdown(this.page, action)
}

// Opens a card from the "All Cards" table (CardPresenter.svelte renders the title through
// DocNavLink, i.e. an <a> element).
async openCard (title: string): Promise<void> {
await this.cardRow(title).locator('a', { hasText: title }).click()
}
}
3 changes: 3 additions & 0 deletions tests/sanity/tests/model/channel-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,9 @@ export class ChannelPage extends CommonPage {
async searchChannel (channelName: string): Promise<void> {
await this.inputSearchIcon().click()
await this.inputSearchChannel().fill(channelName)
// Give the full-text indexer time to pick up a just-created channel before
// the search query is submitted, otherwise the table briefly returns no rows.
await this.page.waitForTimeout(1000)
}

async checkLinkedChannelIsExist (channelName: string, linkedChannelType: LinkedChannelTypes): Promise<void> {
Expand Down
Loading