Skip to content
37 changes: 19 additions & 18 deletions README.md

Large diffs are not rendered by default.

391 changes: 391 additions & 0 deletions docs/tool-roadmap.md

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions e2e/env-diff.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from '@playwright/test';
import { readFile } from 'node:fs/promises';

test('.env Diff compares files with masked values', async ({ page }) => {
const origin = new URL(test.info().project.use.baseURL!).origin;
Expand Down Expand Up @@ -36,6 +37,19 @@ test('.env Diff compares files with masked values', async ({ page }) => {
await expect(page.getByRole('region', { name: 'Output' }).locator('pre')).toHaveText('PORT=\nSHARED=\nAPI_TOKEN=\nPORT=');
await expect(page).not.toHaveURL(/#s=/);

// Keys-only hides values; the masked report and normalized file download without leaking on screen.
await page.getByLabel('Compare keys only').check();
await expect(page.getByText('1 missing · 1 extra · 0 changed · 2 same')).toBeVisible();
await expect(diff.getByRole('button', { name: /Reveal values/ })).toHaveCount(0);
const report = page.waitForEvent('download');
await page.getByRole('button', { name: 'Masked report' }).click();
const reportText = await readFile((await (await report).path())!, 'utf8');
expect(reportText).toContain('missing DB_PASSWORD');
expect(reportText).not.toContain('hunter2');
const env = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download normalized .env' }).click();
expect(await readFile((await (await env).path())!, 'utf8')).toBe('SHARED=same\nAPI_TOKEN=hunter2\nPORT=9090\n');

expect(offOrigin).toEqual([]);
expect(errors).toEqual([]);
});
Expand Down
10 changes: 10 additions & 0 deletions e2e/gitignore-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ test('gitignore Generator works locally', async ({ page }) => {
await expect(results.getByRole('listitem').nth(2)).toContainText('Not ignored');
await expect(results.getByRole('listitem').nth(3)).toContainText('Matched line');

// Hand edits are kept, used by the tester and the download, and survive a template change.
const editor = page.getByLabel('Edit .gitignore');
await editor.fill('src/\n');
await expect(results.getByRole('listitem').nth(1)).toContainText('Ignored');
await page.getByLabel('Python', { exact: true }).uncheck();
await expect(page.getByText('Templates changed since you edited')).toBeVisible();
await expect(editor).toHaveValue('src/\n');
await page.getByRole('button', { name: 'Reset to generated' }).click();
await expect(editor).toHaveValue(/# === Node\.js ===/);

const download = page.waitForEvent('download');
await page.getByRole('button', { name: /Download \.gitignore/ }).click();
expect((await download).suggestedFilename()).toMatch(/gitignore/);
Expand Down
99 changes: 99 additions & 0 deletions e2e/postgres-query-builder.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { expect, test, type Page } from '@playwright/test';

async function loadSample(page: Page, name: RegExp) {
await page.getByRole('combobox', { name: 'Sample' }).click();
await page.getByRole('option', { name }).click();
}

test('builds queries and schema SQL from a sample, locally', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const requests: { url: string; method: string }[] = [];
page.on('request', (r) => requests.push({ url: r.url(), method: r.method() }));
page.on('dialog', (d) => d.accept());

await page.goto('/tools/postgres-query-builder');
await expect(page).toHaveTitle(/^PostgreSQL Query Builder: .+ · Zykit$/);
const origin = new URL(page.url()).origin;
await expect(page.getByText('No schema yet')).toBeVisible();

await loadSample(page, /E-commerce/);
const sql = page.locator('#panel-query pre');
await expect(sql).toHaveText(
'SELECT\n u.name,\n COUNT(o.id) AS order_count\nFROM users AS u\nLEFT JOIN orders AS o\n ON o.user_id = u.id\nGROUP BY\n u.id\nORDER BY\n order_count DESC\nLIMIT 10;',
);
await expect(page.locator('#panel-query').getByText('What this query does')).toBeVisible();

// Add a WHERE filter.
const where = page.getByRole('group', { name: 'WHERE conditions' });
await where.getByRole('button', { name: '+ Condition' }).click();
await where.getByLabel('Column').selectOption({ label: 'u.email' });
await where.getByLabel('Operator').selectOption('ILIKE');
await where.getByLabel('Value').fill('%@example.com');
await expect(sql).toContainText("WHERE u.email ILIKE '%@example.com'\nGROUP BY");

// Invalid configuration shows problems instead of SQL.
await page.getByRole('textbox', { name: 'LIMIT' }).fill('-1');
await expect(page.getByText('Fix these to generate SQL')).toBeVisible();
await expect(sql).toHaveCount(0);
await page.getByRole('textbox', { name: 'LIMIT' }).fill('5');
await expect(sql).toContainText('LIMIT 5;');

// Re-join orders: the suggestion comes from the relationship and must be confirmed.
await page.getByRole('button', { name: 'Remove orders from the query' }).click();
await page.getByLabel('Join another table').selectOption({ label: 'orders' });
const suggestion = page.getByRole('region', { name: 'Join suggestion' });
await expect(suggestion).toContainText('u.id = orders.user_id');
await suggestion.getByRole('button', { name: 'Use this join' }).click();
await expect(sql).toContainText('INNER JOIN orders\n ON orders.user_id = u.id');

await page.getByRole('button', { name: 'Copy SQL' }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('INNER JOIN orders');
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download query.sql' }).click();
expect((await download).suggestedFilename()).toBe('query.sql');

// Schema DDL, then add a table with a relationship.
await page.getByRole('tab', { name: 'Schema Designer' }).click();
const ddl = page.locator('#panel-schema pre');
await expect(ddl).toContainText('CREATE TABLE users (\n id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,');
await expect(ddl).toContainText('CONSTRAINT order_items_order_id_fkey FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE');

await page.getByRole('button', { name: '+ Add table' }).click();
await page.getByLabel('Table name', { exact: true }).fill('reviews');
await page.getByRole('button', { name: '+ Add column' }).click();
await page.getByLabel('Column name').last().fill('product_id');
await page.getByLabel('Type').last().selectOption('BIGINT');
await page.getByLabel('Child table', { exact: true }).selectOption({ label: 'reviews' });
await page.getByLabel('Foreign key column', { exact: true }).selectOption({ label: 'product_id (bigint)' });
await page.getByLabel('Parent table', { exact: true }).selectOption({ label: 'products' });
await page.getByRole('button', { name: 'Add relationship' }).click();
await expect(ddl).toContainText('CREATE TABLE reviews (');
await expect(ddl).toContainText('CONSTRAINT reviews_product_id_fkey FOREIGN KEY (product_id) REFERENCES products (id)');
await expect(ddl).toContainText('CREATE INDEX reviews_product_id_idx ON reviews (product_id);');

await page.getByRole('button', { name: 'Undo' }).click();
await expect(ddl).not.toContainText('reviews_product_id_fkey');
await page.getByRole('button', { name: 'Redo' }).click();
await expect(ddl).toContainText('reviews_product_id_fkey');

// The project survives a reload.
await page.reload();
await page.getByRole('tab', { name: 'Schema Designer' }).click();
await expect(page.locator('#panel-schema pre')).toContainText('reviews_product_id_fkey');

for (const r of requests) {
expect(r.method).toBe('GET');
expect(r.url.startsWith(origin) || r.url.startsWith('blob:') || r.url.startsWith('data:')).toBe(true);
}
});

test('has no horizontal scroll at 320px', async ({ page }) => {
await page.setViewportSize({ width: 320, height: 800 });
await page.goto('/tools/postgres-query-builder');
await loadSample(page, /Blog/);
await expect(page.locator('#panel-query pre')).toContainText('FROM posts AS p');
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
await page.getByRole('tab', { name: 'Schema Designer' }).click();
await expect(page.getByRole('region', { name: 'Schema canvas' })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
});
Loading
Loading