diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba51955..9e87e2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,11 +37,11 @@ jobs: with: go-version-file: ws/.go-version # 3. Clone app + core (the PR's PACKAGE is already checked out into its - # slot; bootstrap --tooling skips dirs that already exist). A sibling + # slot; bootstrap --assemble-only skips dirs that already exist). A sibling # only needs app + core + itself to typecheck + unit-test. - name: Assemble workspace (app + core + drive) working-directory: ws - run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --tooling --with drive + run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --assemble-only --with drive - name: Install (workspace root) working-directory: ws run: npm install @@ -71,7 +71,7 @@ jobs: go-version-file: ws/.go-version - name: Assemble workspace (app + core + drive) working-directory: ws - run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --tooling --with drive + run: TINYCLD_REPO_BASE=https://github.com/tinycld npx @tinycld/bootstrap@latest --assemble-only --with drive - name: Install (workspace root) working-directory: ws run: npm install @@ -80,4 +80,22 @@ jobs: run: npx playwright install --with-deps chromium - name: E2E working-directory: ws/${{ env.PACKAGE }} + env: + # Pre-warm calc's lazy screen chunk in globalSetup so the + # first test per worker doesn't race the cold Metro compile. + # See tinycld/app#18. + TINYCLD_WARM_PACKAGES: calc run: npx tinycld-pkg test:e2e + # Capture Playwright's per-failure artifacts (trace, screenshot, + # video, error-context.md) so a CI failure can be diagnosed + # without a re-run. `if: failure()` skips the upload on green + # runs to save bandwidth + retention. + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + ws/${{ env.PACKAGE }}/test-results/ + ws/${{ env.PACKAGE }}/playwright-report/ + retention-days: 14 diff --git a/playwright.config.ts b/playwright.config.ts index 40ac7d8..1f32043 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,4 +5,14 @@ import appConfig from '../app/playwright.config' const WS_ROOT = path.resolve(import.meta.dirname, '..') const TEST_DIR = path.join(WS_ROOT, 'node_modules', '@tinycld', 'calc', 'tests') -export default defineConfig({ ...appConfig, testDir: TEST_DIR }) +export default defineConfig({ + ...appConfig, + testDir: TEST_DIR, + // Per-test timeout. Default is 30s; calc tests routinely open xlsx + // files (the seeded Team Scorecard, blank workbooks) where the + // grid hydration + xlsx parse pipeline runs end-to-end inside the + // test body. On CI under parallel load that pipeline can take + // 30-60s for the first navigation per worker. 60s gives the spec + // body room after the navigation overhead. + timeout: 60_000, +}) diff --git a/tests/calc.spec.ts b/tests/calc.spec.ts index 57c265a..517b27d 100644 --- a/tests/calc.spec.ts +++ b/tests/calc.spec.ts @@ -13,12 +13,33 @@ test.describe('Calc', () => { // The seeded Team Scorecard.xlsx no longer appears on calc's index // (which is now a panel with three CTAs, not a recent-files list). // Browse to drive's recent view to find it and click through. + // Drive rows on the recent view open a preview pane on single + // click rather than navigating to the package editor. Use the + // row's context menu's "Open in Calc" action to bypass the + // preview and land directly in the calc editor. await page.goto(`/a/${ORG_SLUG}/drive/recent`) - await page.getByText('Team Scorecard.xlsx').click() - - await expect(page.getByText('Name', { exact: true })).toBeVisible() - await expect(page.getByText('Role', { exact: true })).toBeVisible() - await expect(page.getByText('Score', { exact: true })).toBeVisible() + await page.getByText('Team Scorecard.xlsx').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Open in Calc' }).click() + await page.waitForURL(/\/calc\/[^/]+$/, { timeout: 30_000 }) + + // Header row mounts as the xlsx parse + grid hydration completes. + // Header cells appear one-by-one as the xlsx parser yields each + // column to the renderer; on CI under parallel load the gap + // between cells can exceed the default 5s, so each header gets + // its own generous timeout instead of relying on the first one + // to land all three in the same frame. + // Cell A1 / B1 / C1 are uniquely labelled by aria-label rather + // than relying on the inner text — text 'Name' also matches the + // virtualized recent-files "Sort by Name" muted-text header. + await expect(page.getByLabel('Cell A1', { exact: true })).toHaveText('Name', { + timeout: 30_000, + }) + await expect(page.getByLabel('Cell B1', { exact: true })).toHaveText('Role', { + timeout: 15_000, + }) + await expect(page.getByLabel('Cell C1', { exact: true })).toHaveText('Score', { + timeout: 15_000, + }) await expect(page.getByText('Alice', { exact: true })).toBeVisible() await expect(page.getByText('Engineer', { exact: true })).toBeVisible() @@ -27,23 +48,19 @@ test.describe('Calc', () => { await expect(page.getByText('Carol', { exact: true })).toBeVisible() await expect(page.getByText('Manager', { exact: true })).toBeVisible() - // Verify columns are correctly aligned by reading the DOM. A1 - // (Name) and B1 (Role) should be at viewport-x positions exactly - // CELL_WIDTH (96px) apart. + // Verify columns are correctly aligned by reading the DOM + // through the stable Cell-A1/B1/C1 aria-labels (not text content, + // which also matches the recent-view "Sort by Name" header + // outside the grid). A1 and B1 should be at viewport-x + // positions exactly CELL_WIDTH (96px) apart. const positions = await page.evaluate(() => { - const find = (text: string) => { - for (const el of Array.from(document.querySelectorAll('div'))) { - if (el.textContent === text && el.children.length === 0) { - const cell = el.parentElement - if (cell) { - const rect = cell.getBoundingClientRect() - return { left: rect.left, width: rect.width } - } - } - } - return null + const find = (label: string) => { + const el = document.querySelector(`[aria-label="${label}"]`) as HTMLElement | null + if (!el) return null + const rect = el.getBoundingClientRect() + return { left: rect.left, width: rect.width } } - return { name: find('Name'), role: find('Role'), score: find('Score') } + return { name: find('Cell A1'), role: find('Cell B1'), score: find('Cell C1') } }) expect(positions.name).not.toBeNull() expect(positions.role).not.toBeNull() @@ -1682,13 +1699,17 @@ test.describe('Calc CSV import/export', () => { test('Import CSV creates a new spreadsheet from the file picker', async ({ page }) => { await navigateToPackage(page, 'calc') - await expect(page.getByRole('heading', { level: 2, name: 'Calc' }).first()).toBeVisible({ + // The calc index now renders the shared NoFilePanel; CSV import + // happens via the unified "Upload files" card which accepts + // .xlsx and .csv. The file-picker click triggers the same + // CsvImportDialog as the old standalone "Import CSV" button. + await expect(page.getByRole('heading', { level: 1, name: 'A fresh sheet.' })).toBeVisible({ timeout: 30_000, }) const csv = 'Title,Count\r\nApples,12\r\nOranges,7' const fileChooserPromise = page.waitForEvent('filechooser') - await page.getByRole('button', { name: 'Import CSV' }).click() + await page.getByText('Upload files', { exact: true }).click() const chooser = await fileChooserPromise await chooser.setFiles({ name: 'fruit.csv', diff --git a/tests/pivot.spec.ts b/tests/pivot.spec.ts index ff26ee4..7d06855 100644 --- a/tests/pivot.spec.ts +++ b/tests/pivot.spec.ts @@ -484,14 +484,18 @@ async function seedTwoRegionsTwoRows( await typeIntoCell(page, formulaBar, 'B3', '20') } -// Click "New spreadsheet" on the calc index and wait for the workbook -// detail screen to mount. Mirrors the helper in calc.spec.ts — kept -// inline so this spec is self-contained. +// Click "New sheet" on the calc index No-File panel and wait for the +// workbook detail screen to mount. Mirrors the helper in calc.spec.ts +// — kept inline so this spec is self-contained. async function openNewSpreadsheet(page: Page): Promise { - await expect(page.getByRole('heading', { level: 2, name: 'Calc' }).first()).toBeVisible({ + // Wait for the No-File panel's headline to render before clicking + // the create button — handleCreateNew needs useOrgInfo / + // useCurrentUserOrg to resolve first, and if the click races that + // the create silently no-ops and waitForURL hangs. + await expect(page.getByRole('heading', { level: 1, name: 'A fresh sheet.' })).toBeVisible({ timeout: 30_000, }) - await page.getByRole('button', { name: 'New spreadsheet' }).click() + await page.getByRole('button', { name: 'New sheet' }).click() await page.waitForURL(/\/calc\/[^/]+$/, { timeout: 75_000 }) await expect(page.getByLabel('Cell A1', { exact: true })).toBeVisible({ timeout: 75_000 }) }