Skip to content

Commit 4d483d9

Browse files
authored
Merge pull request #392 from mariohmol/feat/playwright-e2e
feat(e2e): add Playwright e2e test infrastructure
2 parents 7a1e22c + 2ea9420 commit 4d483d9

4 files changed

Lines changed: 201 additions & 1 deletion

File tree

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"dist": "npm run build && npm run browserify && cp src/jsgantt.css dist/ && cp dist/jsgantt.js dist/jsgantt.css docs/ && echo 'DIST finished'",
1616
"publishnpm": "npm run dist && npm publish",
1717
"demo-full": "npm run dist && npm run start",
18-
"deploy:demo": "npm run dist && mkdir -p /var/www/test/jsgantt-improved && cp -r docs/. /var/www/test/jsgantt-improved/ && cp dist/jsgantt.js dist/jsgantt.css /var/www/test/jsgantt-improved/"
18+
"deploy:demo": "npm run dist && mkdir -p /var/www/test/jsgantt-improved && cp -r docs/. /var/www/test/jsgantt-improved/ && cp dist/jsgantt.js dist/jsgantt.css /var/www/test/jsgantt-improved/",
19+
"test-e2e": "npx playwright test"
1920
},
2021
"repository": {
2122
"type": "git",
@@ -31,6 +32,7 @@
3132
"@types/node": "^24.3.0"
3233
},
3334
"devDependencies": {
35+
"@playwright/test": "^1.59.1",
3436
"@types/chai": "^4.1.5",
3537
"chai": "^4.1.2",
3638
"http-server": "^14.1.1",

playwright.config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineConfig, devices } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './test/e2e',
5+
timeout: 30_000,
6+
retries: 0,
7+
reporter: 'list',
8+
use: {
9+
baseURL: process.env.JSGANTT_E2E_BASE_URL ?? 'http://localhost:8080',
10+
headless: true,
11+
viewport: { width: 1400, height: 900 },
12+
},
13+
projects: [
14+
{
15+
name: 'chromium',
16+
use: { ...devices['Desktop Chrome'] },
17+
},
18+
],
19+
});

test/e2e/gantt.page.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Page Object Model for the jsGantt demo page.
3+
* Wraps common selectors and helpers used across e2e specs.
4+
*/
5+
6+
import { Page, Locator } from '@playwright/test';
7+
8+
export const DEMO_URL = 'demo.html';
9+
10+
export interface BarMetrics {
11+
id: string;
12+
/** JS-computed left offset in CSS pixels (bar.style.left) */
13+
styleLeft: number;
14+
/** JS-computed width in CSS pixels (bar.style.width) */
15+
styleWidth: number;
16+
/** Actual left position in viewport pixels relative to chart table left edge */
17+
viewportLeft: number;
18+
/** Actual width in viewport pixels */
19+
viewportWidth: number;
20+
}
21+
22+
export interface ColMetrics {
23+
index: number;
24+
text: string;
25+
/** Width in viewport pixels */
26+
viewportWidth: number;
27+
}
28+
29+
export class GanttPage {
30+
readonly page: Page;
31+
32+
constructor(page: Page) {
33+
this.page = page;
34+
}
35+
36+
async goto(url = DEMO_URL) {
37+
await this.page.goto(url, { waitUntil: 'load' });
38+
await this.page.waitForSelector('#embedded-Gantt .gcharttable', { timeout: 10_000 });
39+
// Allow layout to settle
40+
await this.page.waitForTimeout(400);
41+
}
42+
43+
/**
44+
* Apply CSS zoom to the Gantt container, simulating browser-level zoom.
45+
* zoom=1.0 restores 100%.
46+
*/
47+
async applyZoom(zoom: number) {
48+
await this.page.evaluate((z: number) => {
49+
const el = document.querySelector<HTMLElement>('#embedded-Gantt');
50+
if (el) el.style.zoom = z === 1 ? '' : String(z);
51+
}, zoom);
52+
}
53+
54+
/**
55+
* Measure bar positions relative to the chart table's left edge.
56+
* Returns up to `limit` bars.
57+
*/
58+
async getBarMetrics(limit = 8): Promise<BarMetrics[]> {
59+
return this.page.evaluate((lim: number) => {
60+
const chartTable = document.querySelector('.gcharttable');
61+
if (!chartTable) return [];
62+
const chartRect = chartTable.getBoundingClientRect();
63+
return Array.from(document.querySelectorAll('[id*="bardiv_"]'))
64+
.slice(0, lim)
65+
.map(bar => {
66+
const el = bar as HTMLElement;
67+
const rect = el.getBoundingClientRect();
68+
return {
69+
id: el.id.replace(/.*bardiv_/, 'bar_'),
70+
styleLeft: parseInt(el.style.left || '0', 10),
71+
styleWidth: parseInt(el.style.width || '0', 10),
72+
viewportLeft: Math.round((rect.left - chartRect.left) * 100) / 100,
73+
viewportWidth: Math.round(rect.width * 100) / 100,
74+
};
75+
});
76+
}, limit);
77+
}
78+
79+
/**
80+
* Measure the last header row's column widths in viewport pixels.
81+
* The last row is the finest date granularity (day/week/month).
82+
*/
83+
async getColumnMetrics(limit = 20): Promise<ColMetrics[]> {
84+
return this.page.evaluate((lim: number) => {
85+
const headerTable = document.querySelector('.gcharttableh');
86+
if (!headerTable) return [];
87+
const rows = headerTable.querySelectorAll('tr');
88+
const lastRow = rows[rows.length - 1];
89+
if (!lastRow) return [];
90+
return Array.from(lastRow.querySelectorAll('td'))
91+
.slice(0, lim)
92+
.map((td, i) => ({
93+
index: i,
94+
text: (td as HTMLElement).innerText.trim().slice(0, 10),
95+
viewportWidth: Math.round(td.getBoundingClientRect().width * 100) / 100,
96+
}));
97+
}, limit);
98+
}
99+
}

test/e2e/zoom-alignment.spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* E2E tests for issue #23: bar misalignment at non-100% browser zoom.
3+
*
4+
* Root cause: table-layout:auto gives columns fractional pixel widths at
5+
* non-100% zoom. As columns accumulate, bars drift away from their date.
6+
*
7+
* Fix: table-layout:fixed + explicit JS cell widths (already applied).
8+
*
9+
* Test strategy:
10+
* - Apply CSS zoom to #embedded-Gantt (equivalent to browser-level zoom)
11+
* - At each zoom level, assert bars scale proportionally with zoom
12+
* (viewportLeft ≈ styleLeft × zoom ± tolerance)
13+
* - Assert column widths are uniform within each zoom level
14+
* (no fractional-px spread from table-layout:auto)
15+
*/
16+
17+
import { test, expect } from '@playwright/test';
18+
import { GanttPage, DEMO_URL } from './gantt.page';
19+
20+
const ZOOM_LEVELS = [0.75, 0.8, 1.0, 1.25, 1.5];
21+
22+
/** Maximum allowed drift between expected and actual bar position (px). */
23+
const DRIFT_TOLERANCE_PX = 3;
24+
25+
/** Maximum allowed spread between widest and narrowest column at the same zoom (px). */
26+
const COL_SPREAD_TOLERANCE_PX = 1;
27+
28+
test.describe('Issue #23 — bar alignment at non-100% zoom', () => {
29+
for (const zoom of ZOOM_LEVELS) {
30+
const label = `${Math.round(zoom * 100)}%`;
31+
32+
test(`bars are correctly aligned at ${label} zoom`, async ({ page }) => {
33+
const gantt = new GanttPage(page);
34+
await gantt.goto(DEMO_URL);
35+
await gantt.applyZoom(zoom);
36+
37+
const bars = await gantt.getBarMetrics(8);
38+
expect(bars.length, 'should find rendered task bars').toBeGreaterThan(0);
39+
40+
for (const bar of bars) {
41+
const expectedLeft = bar.styleLeft * zoom;
42+
const drift = Math.abs(bar.viewportLeft - expectedLeft);
43+
expect(
44+
drift,
45+
`${bar.id} at zoom ${label}: viewportLeft=${bar.viewportLeft} expected≈${expectedLeft.toFixed(1)}`,
46+
).toBeLessThanOrEqual(DRIFT_TOLERANCE_PX);
47+
}
48+
});
49+
50+
test(`column widths are uniform at ${label} zoom`, async ({ page }) => {
51+
const gantt = new GanttPage(page);
52+
await gantt.goto(DEMO_URL);
53+
54+
// Capture baseline column width at 100% before applying zoom
55+
const baseCols = await gantt.getColumnMetrics(5);
56+
const baseColWidth = baseCols[0]?.viewportWidth ?? 39;
57+
58+
await gantt.applyZoom(zoom);
59+
60+
const cols = await gantt.getColumnMetrics(20);
61+
expect(cols.length, 'should find header columns').toBeGreaterThan(0);
62+
63+
// All columns at the same zoom level should have the same width
64+
const widths = cols.map(c => c.viewportWidth);
65+
const spread = Math.max(...widths) - Math.min(...widths);
66+
expect(
67+
spread,
68+
`column width spread at ${label} zoom (min=${Math.min(...widths).toFixed(2)} max=${Math.max(...widths).toFixed(2)})`,
69+
).toBeLessThanOrEqual(COL_SPREAD_TOLERANCE_PX);
70+
71+
// Column widths should scale proportionally with the zoom factor
72+
const expectedWidth = baseColWidth * zoom;
73+
const avgWidth = widths.reduce((a, b) => a + b, 0) / widths.length;
74+
expect(
75+
Math.abs(avgWidth - expectedWidth),
76+
`avg col width ${avgWidth.toFixed(2)} should be ≈ baseCol(${baseColWidth}) × zoom(${zoom}) = ${expectedWidth.toFixed(2)}`,
77+
).toBeLessThanOrEqual(1);
78+
});
79+
}
80+
});

0 commit comments

Comments
 (0)