Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Constraints are evaluated three-valued on partial assignments, answering valid,

## Explorer

An [explorer](https://exadev.github.io/combinatrix/) runs the library in the browser: build a model with a form (dimensions and their values, and rules as nested conditions, with a control for every kind of node in trilean's schema) or edit it as JSON in the Advanced view, where each is the same model as the other, then count its combinations, list them up to a limit, find dead values, explain why a combination is excluded, build a covering array and check it, and move between a combination and its index. The engine runs in a Web Worker, so the page stays responsive and Cancel stops a long operation. Copy link puts the model, compressed, in the link's fragment, which a browser never sends to a server, so a link is self-contained; `?gist=<id>` loads the first `.json` file of a public gist for a model too large for a link. Models can be saved in the browser and imported or exported as `.json` files. Its source is in `site/` and is not part of the published package.
An [explorer](https://exadev.github.io/combinatrix/) runs the library in the browser: build a model with a form (dimensions and their values, and rules as nested conditions, with a control for every kind of node in trilean's schema) or edit it as JSON in the Advanced view, where each is the same model as the other, then count its combinations, list them up to a limit, find dead values, explain why a combination is excluded, build a covering array and check it, and move between a combination and its index. The engine runs in a Web Worker, so the page stays responsive and Cancel stops a long operation. Copy link puts the model, compressed, in the link's fragment, which a browser never sends to a server, so a link is self-contained, and the address bar holds that same link as the model or the selected index changes (a model too large for an address goes by gist instead); `?gist=<id>` loads the first `.json` file of a public gist for a model too large for a link. Models can be saved in the browser and imported or exported as `.json` files. Its source is in `site/` and is not part of the published package.

## Examples

Expand Down
149 changes: 149 additions & 0 deletions e2e/sharing.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { join } from 'node:path';
import { expect, test } from '@playwright/test';

import type { Page } from '@playwright/test';
import { ADDRESS_DEBOUNCE_MS, MAX_ADDRESS_LENGTH } from '../site/src/address';
import { decodeState, encodedStateOf } from '../site/src/fragment';
import type { JsonObject } from '../site/src/json';
import { Explorer, RESULT_TIMEOUT_MS, STARTING_EXAMPLE } from './explorer';

Expand All @@ -19,6 +21,44 @@ const GIST_MODEL: JsonObject = { dimensions: [{ kind: 'choice', name: 'from-gist
const HTTP_OK = 200;
const HTTP_NOT_FOUND = 404;

/** Characters in a model too large for the address bar, as multiples of the longest address the page writes. Deflate shrinks random base-36 text to about five eighths of its length and base64url grows that by a third, so a text this many times the limit makes a link comfortably longer than it. */
const OVERSIZE_FACTOR = 1.5;

/** The multiplier and increment of a linear congruential generator, so the large model is the same on every run. */
const LCG_MULTIPLIER = 1_103_515_245;
const LCG_INCREMENT = 12_345;

/** The radix that writes a number in the fewest characters, and the characters one number is written in. */
const BASE_36 = 36;
const DIGITS_PER_NUMBER = 6;

/** A text of at least `length` characters that deflate cannot shrink much, the same on every run. */
function incompressibleText(length: number): string {
let seed = 1;
let text = '';
while (text.length < length) {
seed = (Math.imul(seed, LCG_MULTIPLIER) + LCG_INCREMENT) >>> 0;
text += seed.toString(BASE_36).padStart(DIGITS_PER_NUMBER, '0');
}

return text;
}

/** The index the state in a fragment holds, or `undefined` when the fragment holds no state or no index. */
async function decodedIndexOf(hash: string): Promise<number | undefined> {
const encoded = encodedStateOf(hash);

return encoded === undefined ? undefined : (await decodeState(encoded)).index;
}

/** What the page logs when its fragment changes by anything but its own address writes. */
const HASH_CHANGE_MESSAGE = 'hashchange fired';

/** The fragment of the page's address now. */
async function hashOf(page: Page): Promise<string> {
return page.evaluate((): string => location.hash);
}

/** Answers GitHub's gist API from the test, so no request leaves the machine. */
async function serveGist(page: Page, status: number, body: unknown): Promise<string[]> {
const requested: string[] = [];
Expand Down Expand Up @@ -94,6 +134,115 @@ test.describe('share links', () => {
});
});

test.describe('the address bar', () => {
test('stays as it was opened until the model is changed, and returns to it when the model is changed back', async ({ page }) => {
const explorer = await Explorer.open(page);
const opened = page.url();
const text = await (await explorer.advanced()).inputValue();
await page.waitForTimeout(ADDRESS_DEBOUNCE_MS * 2);

expect(page.url()).toBe(opened);
await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'changed', values: ['yes'] }] });
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);
await explorer.setText(text);

await expect.poll(() => page.url(), { timeout: RESULT_TIMEOUT_MS }).toBe(opened);
});

test('follows an edit made in the form, without adding history entries or firing hashchange', async ({ page }) => {
const explorer = await Explorer.open(page);
await explorer.loadExample(STARTING_EXAMPLE);
const hashChanges: string[] = [];
page.on('console', (message) => {
if (message.text() === HASH_CHANGE_MESSAGE) hashChanges.push(message.text());
});
await page.evaluate((message) => {
window.addEventListener('hashchange', () => {
console.log(message);
});
}, HASH_CHANGE_MESSAGE);
const history = await page.evaluate(() => window.history.length);
await explorer.field('dimensions.2.values:add').click();
await explorer.field('dimensions.2.values.3:value').fill('wasmtime');
await explorer.ready();
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);

expect(await page.evaluate(() => window.history.length)).toBe(history);
expect(hashChanges).toEqual([]);
});

test('is the link Copy link makes, so reloading it, or opening it elsewhere, shows the same model and combination', async ({ page, context }) => {
const explorer = await Explorer.open(page);
await explorer.loadExample(STARTING_EXAMPLE);
await explorer.field('dimensions.2.values:add').click();
await explorer.field('dimensions.2.values.3:value').fill('wasmtime');
await explorer.ready();
await explorer.setNumber('index', SHARED_INDEX);
await expect.poll(async () => decodedIndexOf(await hashOf(page)), { timeout: RESULT_TIMEOUT_MS }).toBe(SHARED_INDEX);
const model = await explorer.json();
const link = page.url();
await explorer.id('copy-link').click();
await expect(explorer.id('link-status')).toContainText('Link copied', { timeout: RESULT_TIMEOUT_MS });

expect(await explorer.id('link').inputValue()).toBe(link);
await page.reload();
await explorer.ready();
expect(await explorer.json()).toEqual(model);
await expect(explorer.id('index')).toHaveValue(String(SHARED_INDEX));
const fresh = await context.newPage();
await fresh.goto(link);
const opened = new Explorer(fresh);
await opened.ready();

expect(await opened.json()).toEqual(model);
});

test('keeps text that is not yet valid JSON, so reloading does not lose it', async ({ page }) => {
const explorer = await Explorer.open(page);
await explorer.setText('{ not json yet');
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);
await page.reload();

await expect(explorer.id('model')).toHaveValue('{ not json yet', { timeout: RESULT_TIMEOUT_MS });
});

test('replaces the gist of the address with the edited model, which a reload then shows instead of the gist', async ({ page }) => {
const requested = await serveGist(page, HTTP_OK, { files: { 'model.json': { content: JSON.stringify(GIST_MODEL) } } });
await page.goto(`?gist=${GIST_ID}`);
const explorer = new Explorer(page);
await explorer.ready();
await page.waitForTimeout(ADDRESS_DEBOUNCE_MS * 2);

expect(new URL(page.url()).search).toBe(`?gist=${GIST_ID}`);
await explorer.field('dimensions.0.values:add').click();
await explorer.field('dimensions.0.values.3:value').fill('four');
await explorer.ready();
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);
const edited = await explorer.json();
await page.reload();
await explorer.ready();

expect(new URL(page.url()).search).toBe('');
expect(await explorer.json()).toEqual(edited);
expect(requested).toEqual([`https://api.github.com/gists/${GIST_ID}`]);
});

test('stops following a model too large for an address, says so, and follows again once it is small', async ({ page }) => {
const explorer = await Explorer.open(page);
await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'small', values: ['yes'] }] });
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);
const large = JSON.stringify({ dimensions: [{ kind: 'choice', name: incompressibleText(Math.ceil(MAX_ADDRESS_LENGTH * OVERSIZE_FACTOR)), values: ['yes'] }] });
await explorer.setJson(large);

await expect(explorer.id('address-status')).toContainText('too large', { timeout: RESULT_TIMEOUT_MS });
expect(await hashOf(page)).toBe('');
await explorer.setJson({ dimensions: [{ kind: 'choice', name: 'small again', values: ['yes'] }] });

await expect(explorer.id('address-status')).toHaveText('', { timeout: RESULT_TIMEOUT_MS });
await expect.poll(async () => hashOf(page), { timeout: RESULT_TIMEOUT_MS }).toMatch(/^#s=/);
});
});

test.describe('gists', () => {
test('loads the first .json file of a public gist by id, asking only GitHub for it', async ({ page }) => {
const requested = await serveGist(page, HTTP_OK, { files: { 'notes.md': { content: 'ignore me' }, 'model.json': { content: JSON.stringify(GIST_MODEL) } } });
Expand Down
1 change: 1 addition & 0 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ <h2 id="model-heading">Model</h2>
<label class="file-button">Import<input type="file" id="import" accept=".json,application/json" /></label>
</div>
<p id="link-status" role="status"></p>
<p id="address-status" role="status"></p>
<input id="link" type="text" readonly hidden aria-label="Link to this model" />
</section>

Expand Down
46 changes: 46 additions & 0 deletions site/src/address.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SharedState } from "./fragment";
import { shareUrl } from "./fragment";

/**
* The longest address the page writes into the address bar, in characters. Firefox parses a standard URL up to one mebibyte (Chromium accepts two across its process boundary), so a longer link may be refused by a browser the page cannot ask; a model that large travels as a gist.
*/
export const MAX_ADDRESS_LENGTH = 1_048_576;

/** WebKit throws a `SecurityError` from `history.replaceState` past this many calls in one window of time. */
const WEBKIT_REPLACE_STATE_CALLS = 100;

/** The window of time, in milliseconds, in which WebKit counts `history.replaceState` calls. */
const WEBKIT_REPLACE_STATE_WINDOW_MS = 30_000;

/**
* How long after the last change the address is written. Writes are at least this far apart, because a write is made this long after the last change that came before it, so at most `WEBKIT_REPLACE_STATE_CALLS` happen in WebKit's window.
*/
export const ADDRESS_DEBOUNCE_MS = WEBKIT_REPLACE_STATE_WINDOW_MS / WEBKIT_REPLACE_STATE_CALLS;

/** What the address bar should hold for the page's current state. */
export type Address =
| { readonly kind: "unchanged"; readonly href: string }
| { readonly kind: "link"; readonly href: string }
| { readonly kind: "too-large"; readonly href: string };

/** The state a page opened with, and the address it was opened at. */
export interface Opened {
readonly href: string;
readonly state: SharedState;
}

/**
* The address for `state`. A state equal to the one the page opened with keeps the address it opened at, so a page that has not been changed (or has been changed back) shows the address it was given: a clean one, or a gist's. Any other state is a link holding it, as Copy link would make, unless that link is longer than `maxLength`, in which case the address is the page's own, without its query or fragment, because a stale fragment would restore a model the page no longer shows.
*/
export async function addressFor(opened: Opened, current: string, state: SharedState, maxLength = MAX_ADDRESS_LENGTH): Promise<Address> {
if (state.model === opened.state.model && state.index === opened.state.index) return { kind: "unchanged", href: opened.href };

const link = await shareUrl(current, state);
if (link.length <= maxLength) return { kind: "link", href: link };

const bare = new URL(current);
bare.search = "";
bare.hash = "";

return { kind: "too-large", href: bare.href };
}
78 changes: 78 additions & 0 deletions site/src/address.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";

import { ADDRESS_DEBOUNCE_MS, addressFor } from "./address";
import { decodeState, encodedStateOf } from "./fragment";

const PAGE = "https://example.com/combinatrix/";
const OPENED = { href: `${PAGE}?gist=0123abcd`, state: { model: '{"dimensions":[]}', index: 0 } };

/** A model long enough that its link is clearly longer than the small limit the test sets. */
const OVERSIZE_CHARACTERS = 1000;

/** A maximum length that no link in these tests reaches, so the limit is not what they are about. */
const NO_LIMIT = Number.MAX_SAFE_INTEGER;

describe("addressFor", () => {
it("keeps the address the page opened at while the state is the one it opened with", async () => {
expect(await addressFor(OPENED, `${PAGE}#s=anything`, { ...OPENED.state })).toEqual({ kind: "unchanged", href: OPENED.href });
});

it("keeps the opened address for a page that opened without an index until an index is chosen", async () => {
const opened = { href: PAGE, state: { model: "{}" } };

expect(await addressFor(opened, PAGE, { model: "{}" })).toEqual({ kind: "unchanged", href: PAGE });
expect((await addressFor(opened, PAGE, { model: "{}", index: 0 })).kind).toBe("link");
});

it("makes a link holding the changed model, with the query of the current address dropped", async () => {
const changed = { model: '{"dimensions":[{"kind":"choice","name":"os","values":["linux"]}]}', index: 3 };
const address = await addressFor(OPENED, `${PAGE}?gist=0123abcd`, changed, NO_LIMIT);

expect(address.kind).toBe("link");
const url = new URL(address.href);
expect(url.search).toBe("");
const encoded = encodedStateOf(url.hash);
expect(encoded).toBeDefined();
expect(await decodeState(String(encoded))).toEqual(changed);
});

it("makes the same link Copy link would make for the same state", async () => {
const changed = { model: '{"dimensions":[]} ', index: 1 };
const first = await addressFor(OPENED, PAGE, changed, NO_LIMIT);
const second = await addressFor(OPENED, first.href, changed, NO_LIMIT);

expect(second.href).toBe(first.href);
});

it("falls back to the opened address once a changed state is changed back", async () => {
const changed = { model: "{}", index: 0 };
const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT);
const back = await addressFor(OPENED, link.href, { ...OPENED.state }, NO_LIMIT);

expect(back).toEqual({ kind: "unchanged", href: OPENED.href });
});

it("drops the query and fragment, and says so, for a link longer than the limit", async () => {
const changed = { model: "x".repeat(OVERSIZE_CHARACTERS), index: 0 };
const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT);
const address = await addressFor(OPENED, `${PAGE}?gist=0123abcd#s=stale`, changed, link.href.length - 1);

expect(address).toEqual({ kind: "too-large", href: PAGE });
});

it("accepts a link exactly as long as the limit", async () => {
const changed = { model: "{}", index: 0 };
const link = await addressFor(OPENED, PAGE, changed, NO_LIMIT);

expect((await addressFor(OPENED, PAGE, changed, link.href.length)).kind).toBe("link");
});
});

describe("ADDRESS_DEBOUNCE_MS", () => {
it("spaces writes so that WebKit's window never holds more than its limit", () => {
const WINDOW_MS = 30_000;
const CALLS = 100;

expect(Math.floor(WINDOW_MS / ADDRESS_DEBOUNCE_MS)).toBeLessThanOrEqual(CALLS);
});
});
Loading
Loading