Out of arena
Where Playwright runs out of road in public SaaS
Modern web spreadsheets and enterprise data grids render to <canvas>
for performance, putting the working surface beyond the reach of selector-based automation. Below: the canvas
pattern at scale, why ERP + spreadsheets is the worst-affected combination, and a live Playwright-vs-AIVA
demo against Odoo Spreadsheet as the exemplar.
The pattern, at scale
900M+
monthly users on a canvas-rendered spreadsheet
Google Sheets alone; Microsoft Excel adds another 750M+ (Microsoft, 2017 baseline), Smartsheet runs at 85% of Fortune 500. Google Docs migrated to canvas in May 2021 and broke a swathe of Chrome extensions in the process — selector tools only see the chrome around the grid.
The category
71%
of FP&A teams own EPM tools — and still run on spreadsheets
2025 AFP FP&A Benchmarking Survey. A separate 700-leader survey: 40% of businesses still manage half their financial data manually; 26% manage the majority manually (FinTech Strategy, 2025). Spreadsheets stay the de facto ERP surface — and vendors ship canvas spreadsheets to plug the gap.
Our exemplar
13M+
Odoo users — the canvas-ERP pattern with a public trial
40% YoY growth, 7,000 new clients/month, €5 billion valuation (Odoo SA, Nov 2024). ~15% of the SMB ERP market, projected 25% by 2027. Their Spreadsheet ships in every Enterprise edition — and the free trial lets us demonstrate the failure publicly, against a real grid, without violating anyone's terms.
Exemplar · live demo
Add a purchase order to Odoo Spreadsheet
Open-source ERPhttps://testforme.odoo.com/odoo
Goal
A procurement clerk needs to record a new incoming order — PO-2026-3123, 700 units of RM-3002 (Stainless 316L bar) from NorthSteel Foundry, due 2026-05-22, status Draft — in the company's Odoo Spreadsheet ERP workbook.
Steps
- Log in to Odoo and open the ERP workbook in the Documents app.
- Navigate to the Purchase Orders register.
- Open a new row above the TOTAL line for the incoming order.
- Fill in PO #, order date, supplier, SKU, description, qty, unit cost, net total, expected receipt and status.
- Save and verify the order was recorded.
Problem
The spreadsheet renders to a single <canvas>, and the chrome around it (Name Box, menus, formula bar) uses class names Odoo wraps differently from the o-spreadsheet library docs. Playwright never reaches the canvas — it can't get past the chrome.
Where Playwright bounces off
- ✓ Reaches Outer Odoo DOM — login form, Documents app, file card
Standard locators work here:
input[name="login"],.o_kanban_record, the Documents nav link. This is what the recording shows succeeding for the first ~10 seconds. - ✗ Stops here o-spreadsheet chrome — Name Box, top-bar menus, sheet tabs, formula bar
The library docs claim
.o-name-box,.o-topbar-menu,.o-sheet,.o-formula-bar. Odoo's wrapped build exposes none of them — every documented selector resolves to zero elements. The test stops here, timing out on the first Name-Box click. - · No DOM <canvas> grid — every cell, gridline, total, conditional fill
Painted into a single canvas element with no DOM children. Even if a future test author reverse-engineers the chrome selectors, no per-cell locator exists; verification has no DOM surface to assert against.
AIVA reads all three layers as rendered pixels — the layer separation doesn't apply. Login form, sheet tabs, and grid cells are visually identical inputs to the same vision model.
Playwright recording (23 s)
AIVA recording (same task — 2× speed)
AIVA driving the same Odoo Spreadsheet task end-to-end — selecting the TOTAL row, inserting the row above, typing the ten Purchase Order cells across, and verifying the result — by looking at the rendered pixels the same way a human operator does.
Show AIVA's step-by-step run log
The AIVA test-run log records every step it took on the canvas — clicking "TOTAL", opening Insert → Row above, typing each value, pressing Tab. The screenshot is exported from AIVA's test-runs viewer. Login credentials have been redacted for publication.
Show the Playwright test expand collapse
import { test, expect, type Page } from '@playwright/test';
const START_URL = 'https://testforme.odoo.com/odoo';
const EMAIL = process.env.ODOO_EMAIL!;
const PASSWORD = process.env.ODOO_PASSWORD!;
const FILE_NAME = 'odoo-erp-mock';
const TOTAL_ROW_REF = 'A15';
const NEW_ROW_VALUES = [
'PO-2026-3123', '2026-05-02', 'NorthSteel Foundry', 'RM-3002',
'Stainless 316L bar — 60mm', '700', '14.20', '9940.00',
'2026-05-22', 'Draft',
] as const;
test('insert a row in Purchase Orders, fill it, verify', async ({ page }) => {
test.setTimeout(180_000);
// 1. Log in
await page.goto(START_URL);
if (page.url().includes('/web/login')) {
await page.locator('input[name="login"]').fill(EMAIL);
await page.locator('input[name="password"]').fill(PASSWORD);
await page.getByRole('button', { name: /log in/i }).click();
await page.waitForURL((u) => !u.pathname.startsWith('/web/login'));
}
// 2. Open Documents → odoo-erp-mock.xlsx
await page.getByRole('link', { name: /^Documents$/ }).click();
await page
.locator('.o_kanban_record, .o_data_row')
.filter({ hasText: FILE_NAME })
.first()
.dblclick();
await page.locator('.o-grid canvas').first().waitFor();
// 3. Switch to Purchase Orders sheet (sheet tabs ARE real DOM)
await page.locator('.o-sheet').filter({ hasText: /^Purchase Orders$/ }).first().click();
// 4. Select A15 (TOTAL) and Insert → Row → Above
await selectCellByReference(page, TOTAL_ROW_REF);
await page.locator('.o-topbar-menu').filter({ hasText: /^Insert$/ }).first().click();
await page.locator('.o-menu-item').filter({ hasText: /^Row(s)?$/i }).first().click();
await page.locator('.o-menu-item').filter({ hasText: /Row\s+above/i }).first().click();
// 5. Re-select A15 (now empty) and type ten cells across
await selectCellByReference(page, TOTAL_ROW_REF);
for (let i = 0; i < NEW_ROW_VALUES.length; i++) {
await page.keyboard.type(NEW_ROW_VALUES[i], { delay: 20 });
await page.keyboard.press(i < NEW_ROW_VALUES.length - 1 ? 'Tab' : 'Enter');
}
// 6. Verify: re-select A15 and read the formula bar
await selectCellByReference(page, TOTAL_ROW_REF);
const composer = page
.locator('.o-spreadsheet-topbar .o-composer, .o-formula-bar')
.first();
const value = (await composer.textContent())?.trim() ?? '';
expect(value).toBe(NEW_ROW_VALUES[0]); // ← fails: returns "" under headless timing
});
// Name Box: real-DOM <input> at top-left of the grid that selects a cell
// by A1-reference. The only navigation path that works without per-cell DOM.
async function selectCellByReference(page: Page, ref: string) {
const nameBox = page.locator('.o-name-box input, [class*="name-box"] input').first();
await nameBox.click();
await page.keyboard.press('ControlOrMeta+A');
await page.keyboard.type(ref);
await page.keyboard.press('Enter');
await page.waitForTimeout(80);
} TimeoutError: locator.click: Timeout 15000ms exceeded. waiting for locator('.o-name-box input, [class*="name-box"] input, …').first()