taskListRowLayout.test.ts3.4 KBView on GitHub
/**
 * The checkbox and its text must sit on ONE row.
 *
 * TipTap renders a task item as `<li><label><input></label><div><p>text</p></div></li>`. `label`
 * is inline and `div` is block, so with no layout rule the text starts on the line BELOW the
 * checkbox — reported as "the text renders significantly lower than the checkbox". Only the input
 * was styled; the row was never laid out.
 *
 * jsdom does no layout, so this cannot assert pixels. It asserts the two things that were actually
 * wrong and that a future edit could quietly break: the DOM shape the CSS selectors depend on, and
 * the presence of the rules that lay it out. If TipTap changes `li > div` to something else, the
 * first half fails; if someone deletes the flex rule, the second does.
 */
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { Editor } from '@tiptap/core';
import { Markdown } from '@tiptap/markdown';
import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';

const GLOBALS = readFileSync(join(process.cwd(), 'app/globals.css'), 'utf8');

function renderTaskList(markdown: string): string {
  const editor = new Editor({
    extensions: [StarterKit, Markdown, TaskList, TaskItem.configure({ nested: true })],
    content: markdown,
    // Same handshake the coaching-fence test uses — without it the Markdown extension never
    // parses and the checklist arrives as a literal paragraph.
    contentType: 'markdown' as never,
  });
  const html = editor.getHTML();
  editor.destroy();
  return html;
}

describe('task-list rows', () => {
  it('renders the label and the content as siblings inside the li — the shape the CSS targets', () => {
    const html = renderTaskList('- [ ] Asked "what piqued your interest"\n');
    expect(html).toContain('data-type="taskList"');
    expect(html).toContain('data-type="taskItem"');
    // The two children the layout rules select. Both must exist, or `li > label` / `li > div`
    // select nothing and the row silently goes back to stacking.
    expect(html).toMatch(/<li[^>]*>\s*<label>/);
    expect(html).toMatch(/<\/label>\s*<div>/);
  });

  it('lays the row out as a flex line, not two stacked blocks', () => {
    // The bug in one assertion: without this rule the div wraps below the label.
    expect(GLOBALS).toMatch(/ul\[data-type='taskList'\] li \{[^}]*display:\s*flex/);
    expect(GLOBALS).toMatch(/ul\[data-type='taskList'\] li \{[^}]*align-items:\s*flex-start/);
  });

  it('gives the label a fixed width and the content the remainder', () => {
    expect(GLOBALS).toMatch(/ul\[data-type='taskList'\] li > label \{[^}]*flex:\s*0 0 auto/);
    expect(GLOBALS).toMatch(/ul\[data-type='taskList'\] li > div \{[^}]*flex:\s*1 1 auto/);
    // Without min-width:0 a long unbroken token pushes the checkbox off the row.
    expect(GLOBALS).toMatch(/ul\[data-type='taskList'\] li > div \{[^}]*min-width:\s*0/);
  });

  it('keeps the 1px nudge on the markdown path only, where there is no flex label', () => {
    // remarkGfm (chat, draft bodies, roadmap) emits the input inline with no wrapper, so it still
    // needs the baseline nudge. Applying it to the TipTap box too would double-count.
    expect(GLOBALS).toMatch(
      /li\.task-list-item input\[type='checkbox'\],\s*\n\s*ul\.contains-task-list input\[type='checkbox'\] \{\s*\n\s*margin-top: 1px;/,
    );
  });
});