set up e2e tests and useShape/useSignal hooks

main
Laurin Weger 2 weeks ago
parent 7dbc947abd
commit ed6765fbf6
No known key found for this signature in database
GPG Key ID: 9B372BB0B792770F
  1. 8
      playwright.config.ts
  2. 3
      src/app/components/Highlight.astro
  3. 131
      src/frontends/react/HelloWorld.tsx
  4. 135
      src/frontends/svelte/HelloWorld.svelte
  5. 171
      src/frontends/tests/reactiveCrossFramework.spec.ts
  6. 43
      src/frontends/utils/flattenObject.ts
  7. 130
      src/frontends/vue/HelloWorld.vue
  8. 8
      src/ng-mock/js-land/connector/applyDiff.ts
  9. 72
      src/ng-mock/js-land/connector/createSignalObjectForShape.ts
  10. 1
      src/ng-mock/js-land/frontendAdapters/react/index.ts
  11. 119
      src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts
  12. 11
      src/ng-mock/js-land/frontendAdapters/react/useShape.ts
  13. 98
      src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts
  14. 16
      src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts
  15. 26
      src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts
  16. 3
      src/ng-mock/js-land/frontendAdapters/vue/index.ts
  17. 9
      src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts
  18. 16
      src/ng-mock/js-land/frontendAdapters/vue/useShape.ts
  19. 2
      src/ng-mock/js-land/types.ts
  20. 24
      src/ng-mock/wasm-land/requestShape.ts
  21. 437
      tests-examples/demo-todo-app.spec.ts

@ -34,10 +34,10 @@ export default defineConfig({
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ // {
name: "chromium", // name: "chromium",
use: { ...devices["Desktop Chrome"] }, // use: { ...devices["Desktop Chrome"] },
}, // },
{ {
name: "firefox", name: "firefox",

@ -21,7 +21,8 @@ const frameworkName = Object.keys(Astro.props)[0];
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
max-width: 60%;
min-width: 60%;
} }
.wrap { .wrap {
padding: 10px; padding: 10px;

@ -1,26 +1,143 @@
import React from "react"; import React from "react";
import useShape from "../../ng-mock/js-land/frontendAdapters/react/useShape"; import useShape from "../../ng-mock/js-land/frontendAdapters/react/useShape";
import flattenObject from "../utils/flattenObject";
export function HelloWorldReact() { export function HelloWorldReact() {
const state = useShape("Shape1", ""); const state = useShape("TestShape", "");
// @ts-expect-error
window.reactState = state; window.reactState = state;
console.log("react render", state); // console.log("[react] rendering", state);
if (!state) return <>Loading state</>; if (!state) return <>Loading state</>;
// Create a table from the state object: One column for keys, one for values, one with an input to change the value.
return ( return (
<div> <div>
<p>Hello World from React!</p> <p>Rendered in React</p>
{state.name} lives at {state.address.street}
<p></p> <table border={1} cellPadding={5}>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
{(() => {
const setNestedValue = (obj: any, path: string, value: any) => {
const keys = path.split(".");
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
};
const getNestedValue = (obj: any, path: string) => {
return path
.split(".")
.reduce((current, key) => current[key], obj);
};
return flattenObject(state).map(([key, value]) => (
<tr key={key}>
<td>{key}</td>
<td>
{value instanceof Set
? Array.from(value).join(", ")
: Array.isArray(value)
? `[${value.join(", ")}]`
: JSON.stringify(value)}
</td>
<td>
{typeof value === "string" ? (
<input
type="text"
value={value}
onChange={(e) => {
setNestedValue(state, key, e.target.value);
}}
/>
) : typeof value === "number" ? (
<input
type="number"
value={value}
onChange={(e) => {
setNestedValue(state, key, Number(e.target.value));
}}
/>
) : typeof value === "boolean" ? (
<input
type="checkbox"
checked={value}
onChange={(e) => {
setNestedValue(state, key, e.target.checked);
}}
/>
) : Array.isArray(value) ? (
<div>
<button <button
onClick={() => { onClick={() => {
state.name = `${state.name} ${state.name}`; const currentArray = getNestedValue(state, key);
setNestedValue(state, key, [
...currentArray,
currentArray.length + 1,
]);
}} }}
> >
Double name Add
</button> </button>
<button
onClick={() => {
const currentArray = getNestedValue(state, key);
if (currentArray.length > 0) {
setNestedValue(
state,
key,
currentArray.slice(0, -1)
);
}
}}
>
Remove
</button>
</div>
) : value instanceof Set ? (
<div>
<button
onClick={() => {
const currentSet = getNestedValue(state, key);
currentSet.add(`item${currentSet.size + 1}`);
}}
>
Add
</button>
<button
onClick={() => {
const currentSet = getNestedValue(state, key);
const lastItem = Array.from(currentSet).pop();
if (lastItem) {
currentSet.delete(lastItem);
}
}}
>
Remove
</button>
</div>
) : (
"N/A"
)}
</td>
</tr>
));
})()}
</tbody>
</table>
</div> </div>
); );
} }

@ -1,19 +1,126 @@
<script> <script lang="ts">
import useShape from "../../ng-mock/js-land/frontendAdapters/svelte/useShape"; import useShape from "../../ng-mock/js-land/frontendAdapters/svelte/useShape.svelte";
import flattenObject from "../utils/flattenObject";
import type { Writable } from "svelte/store";
const nestedObject = useShape("Shape1", null); const shapeObject = useShape("TestShape");
</script>
$effect(() => {
console.log("[svelte]", $shapeObject.objectValue.nestedString);
});
<div> function getNestedValue(obj: any, path: string) {
<p>Hello World from Svelte!</p> return path
.split(".")
.reduce((cur, k) => (cur == null ? cur : cur[k]), obj);
}
function setNestedValue(obj: any, path: string, value: any) {
const keys = path.split(".");
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
cur = cur[keys[i]];
if (cur == null) return;
}
cur[keys[keys.length - 1]] = value;
}
const flatEntries = $derived(
$shapeObject ? flattenObject($shapeObject as any) : []
);
$effect(() => {
(window as any).svelteState = $shapeObject;
});
</script>
<pre>{JSON.stringify($nestedObject, null, 4)}</pre> {#if $shapeObject}
<div>
<p>Rendered in Svelte</p>
<table border="1" cellpadding="5">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
{#each flatEntries as [key, value] (key)}
<tr>
<td style="white-space:nowrap;">{key}</td>
<td>
{#if value instanceof Set}
{Array.from(value).join(", ")}
{:else if Array.isArray(value)}
[{value.join(", ")}]
{:else}
{JSON.stringify(value)}
{/if}
</td>
<td>
{#if typeof value === "string"}
<input
type="text"
{value}
oninput={(e: any) =>
setNestedValue($shapeObject, key, e.target.value)}
/>
{:else if typeof value === "number"}
<input
type="number"
{value}
oninput={(e: any) =>
setNestedValue($shapeObject, key, Number(e.target.value))}
/>
{:else if typeof value === "boolean"}
<input
type="checkbox"
checked={value}
onchange={(e: any) =>
setNestedValue($shapeObject, key, e.target.checked)}
/>
{:else if Array.isArray(value)}
<div style="display:flex; gap:.5rem;">
<button
onclick={() => {
const cur = getNestedValue($shapeObject, key) || [];
setNestedValue($shapeObject, key, [
...cur,
cur.length + 1,
]);
}}>Add</button
>
<button
onclick={() => {
const cur = getNestedValue($shapeObject, key) || [];
if (cur.length)
setNestedValue($shapeObject, key, cur.slice(0, -1));
}}>Remove</button
>
</div>
{:else if value instanceof Set}
<div style="display:flex; gap:.5rem;">
<button
onclick={() => {
const cur: Set<any> = getNestedValue($shapeObject, key);
cur.add(`item${cur.size + 1}`);
}}>Add</button
>
<button <button
on:click={() => { onclick={() => {
window.svelteState = $nestedObject; const cur: Set<any> = getNestedValue($shapeObject, key);
$nestedObject.name = $nestedObject.name.toUpperCase(); const last = Array.from(cur).pop();
}} if (last !== undefined) cur.delete(last);
}}>Remove</button
> >
upper-case name </div>
</button> {:else}
</div> N/A
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p>Loading state</p>
{/if}

@ -1,5 +1,20 @@
import { test, expect } from "@playwright/test"; import { test, expect } from "@playwright/test";
const mockTestObject = {
type: "TestObject",
stringValue: "string",
numValue: 42,
boolValue: true,
nullValue: null,
arrayValue: [1, 2, 3],
objectValue: {
nestedString: "nested",
nestedNum: 7,
nestedArray: [10, 12],
},
setValue: new Set(["v1", "v2", "v3"]),
};
test("components load", async ({ page }) => { test("components load", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await page.waitForSelector(".vue astro-island"); await page.waitForSelector(".vue astro-island");
@ -8,3 +23,159 @@ test("components load", async ({ page }) => {
await expect(page.locator(".react .title")).toHaveText("react"); await expect(page.locator(".react .title")).toHaveText("react");
await expect(page.locator(".svelte .title")).toHaveText("svelte"); await expect(page.locator(".svelte .title")).toHaveText("svelte");
}); });
// TODO: Test without signal pooling.
test.describe("cross framework propagation", () => {
const frameworks = ["vue", "react", "svelte"] as const;
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" &&
v !== null &&
!Array.isArray(v) &&
!(v instanceof Set);
function changedValue(original: unknown) {
if (typeof original === "string") return original + "_changed";
if (typeof original === "number") return original + 10;
if (typeof original === "boolean") return !original;
if (Array.isArray(original)) return [...original, {}, {}];
if (original instanceof Set) new Set(original).add("_changed");
return original;
}
async function mutateCell(
row: ReturnType<(typeof test)["info"] extends any ? any : never>,
original: unknown
) {
if (typeof original === "string") {
const input = row.locator("input[type='text']");
await input.fill(String(changedValue(original)));
await input.blur();
} else if (typeof original === "number") {
const input = row.locator("input[type='number']");
await input.fill(String(changedValue(original)));
await input.blur();
} else if (typeof original === "boolean") {
const input = row.locator("input[type='checkbox']");
await input.setChecked(Boolean(changedValue(original)));
} else if (Array.isArray(original)) {
const addButton = row.locator("button", { hasText: "Add" });
await addButton.click();
await addButton.click();
}
}
async function assertCell(
row: ReturnType<(typeof test)["info"] extends any ? any : never>,
original: unknown,
meta: { framework: string; key: string }
) {
const { framework, key } = meta;
const expected = changedValue(original);
const cell = row.locator("td").nth(1);
if (typeof original === "string") {
const input = row.locator("input[type='text']");
await expect(
input,
`Text value mismatch (${framework}:${key})`
).toHaveValue(String(expected));
await expect(
cell,
`Rendered text mismatch (${framework}:${key})`
).toContainText(String(expected));
} else if (typeof original === "number") {
const input = row.locator("input[type='number']");
await expect(
input,
`Number value mismatch (${framework}:${key})`
).toHaveValue(String(expected));
await expect(
cell,
`Rendered number mismatch (${framework}:${key})`
).toContainText(String(expected));
} else if (typeof original === "boolean") {
const input = row.locator("input[type='checkbox']");
await expect(
input,
`Checkbox state mismatch (${framework}:${key})`
).toBeChecked({
checked: Boolean(expected),
});
await expect(
cell,
`Rendered boolean mismatch (${framework}:${key})`
).toContainText(String(expected));
} else if (Array.isArray(original)) {
const expectedLength = (original as unknown[]).length + 2;
await expect(
cell,
`Array length mismatch (${framework}:${key}) expected ${expectedLength}`
).toContainText(String(expectedLength));
}
}
for (const source of frameworks) {
for (const target of frameworks) {
if (source === target) continue;
test(`${source} edits propagate to ${target}`, async ({ page }) => {
test.fail(
source === "vue" || target === "vue",
"Vue propagation currently expected to fail"
);
await page.goto("/");
await page.waitForSelector(".vue astro-island");
// Mutate in source
await test.step(`Mutate values in ${source}`, async () => {
for (const [key, value] of Object.entries(mockTestObject)) {
if (isPlainObject(value)) {
for (const [k2, v2] of Object.entries(value)) {
const fullKey = `${key}.${k2}`;
const row = page.locator(`.${source} tr`, { hasText: fullKey });
await mutateCell(row, v2);
}
} else {
const row = page.locator(`.${source} tr`, { hasText: key });
await mutateCell(row, value);
}
}
});
// Assert in target
await test.step(`Assert propagation into ${target}`, async () => {
for (const [key, value] of Object.entries(mockTestObject)) {
if (isPlainObject(value)) {
for (const [k2, v2] of Object.entries(value)) {
const fullKey = `${key}.${k2}`;
const row = page.locator(`.${target} tr`, { hasText: fullKey });
await assertCell(row, v2, { framework: target, key: fullKey });
}
} else {
const row = page.locator(`.${target} tr`, { hasText: key });
await assertCell(row, value, { framework: target, key });
}
}
});
// Optional: also ensure source reflects its own changes (helps isolate failures)
await test.step(`Validate mutated source ${source}`, async () => {
for (const [key, value] of Object.entries(mockTestObject)) {
if (isPlainObject(value)) {
for (const [k2, v2] of Object.entries(value)) {
const fullKey = `${key}.${k2}`;
const row = page.locator(`.${source} tr`, { hasText: fullKey });
await assertCell(row, v2, { framework: source, key: fullKey });
}
} else {
const row = page.locator(`.${source} tr`, { hasText: key });
await assertCell(row, value, { framework: source, key });
}
}
});
});
}
}
});

@ -0,0 +1,43 @@
interface FlattenOptions {
/** Maximum depth to traverse (default: 8). */
maxDepth?: number;
/** Skip keys that start with a dollar sign (deepSignal meta). Default: true */
skipDollarKeys?: boolean;
}
const isPlainObject = (v: any) =>
Object.prototype.toString.call(v) === "[object Object]";
const flattenObject = (
obj: any,
prefix = "",
options: FlattenOptions = {},
seen = new Set<any>(),
depth = 0
): Array<[string, any]> => {
const { maxDepth = 8, skipDollarKeys = true } = options;
const result: Array<[string, any]> = [];
if (!obj || typeof obj !== "object") return result;
if (seen.has(obj)) return result; // cycle detected
seen.add(obj);
if (depth > maxDepth) return result;
for (const [key, value] of Object.entries(obj)) {
if (skipDollarKeys && key.startsWith("$")) continue;
const fullKey = prefix ? `${prefix}.${key}` : key;
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
!(value instanceof Set) &&
isPlainObject(value)
) {
result.push(...flattenObject(value, fullKey, options, seen, depth + 1));
} else {
result.push([fullKey, value]);
}
}
return result;
};
export default flattenObject;

@ -1,29 +1,129 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WritableComputedRef } from 'vue'; import { watch } from 'vue';
import useShape from '../../ng-mock/js-land/frontendAdapters/vue/useShape'; import useShape from '../../ng-mock/js-land/frontendAdapters/vue/useShape';
import { deepComputed } from '../../ng-mock/js-land/frontendAdapters/vue/deepComputed';
import flattenObject from '../utils/flattenObject';
const shapeObj = useShape("Shape1", "null"); // Acquire deep signal object (proxy) for a shape; scope second arg left empty string for parity
const shapeObj = useShape('TestShape', '');
window.vueState= shapeObj; // Expose for devtools exploration
// @ts-ignore
window.vueState = shapeObj;
const setName = () => { // Helpers to read / write nested properties given a dot path produced by flattenObject
shapeObj.value.name = "Bobby" function getNestedValue(obj: any, path: string) {
return path.split('.').reduce((cur, k) => (cur == null ? cur : cur[k]), obj);
}
function setNestedValue(obj: any, path: string, value: any) {
const keys = path.split('.');
let cur = obj;
for (let i = 0; i < keys.length - 1; i++) {
cur = cur[keys[i]];
if (cur == null) return;
} }
cur[keys[keys.length - 1]] = value;
}
// Reactive flattened entries built via deepComputed bridging deepSignal dependencies to Vue.
const flatEntries = deepComputed(() => (shapeObj ? flattenObject(shapeObj.value as any) : []));
// log flatEntries
watch(flatEntries, (newVal) => {
console.log('flatEntries changed:', newVal);
}, { deep: true });
</script> </script>
<template> <template>
<div> <div class="vue">
<p>Hello World from Vue!</p> <p>Rendered in Vue</p>
<div v-if="shapeObj != null"> <template v-if="shapeObj">
<p>Type is <em>{{shapeObj.type}}</em> with street <em>{{shapeObj.address.street}}</em></p>
<table border="1" cellpadding="5" style="margin-top:1rem; max-width:100%; font-size:0.9rem;">
<button @click="setName"> <thead>
Click to switch name <tr>
</button> <th>Key</th>
<th>Value</th>
<th>Edit</th>
</tr>
</thead>
<tbody>
<tr v-for="([key, value]) in flatEntries" :key="key">
<!-- Key-->
<td style="white-space:nowrap;">{{ key }}</td>
<!-- Value -->
<td>
<template v-if="value instanceof Set">
{{ Array.from(value).join(', ') }}
</template>
<template v-else-if="Array.isArray(value)">
[{{ value.join(', ') }}]
</template>
<template v-else>
{{ JSON.stringify(value) }}
</template>
</td>
<!-- Edit -->
<td>
<!-- String editing -->
<template v-if="typeof value === 'string'">
<template v-if="key.indexOf('.') === -1">
<input type="text" v-model="(shapeObj)[key]" />
</template>
<template v-else>
<input type="text" v-bind:value="value" />
</template>
</template>
<!-- Number editing -->
<template v-else-if="typeof value === 'number'">
<template v-if="key.indexOf('.') === -1">
<input type="number" v-model.number="(shapeObj)[key]" />
</template>
<template v-else>
<input type="number" v-bind:value="value" />
</template>
</template>
<!-- Boolean editing -->
<template v-else-if="typeof value === 'boolean'">
<template v-if="key.indexOf('.') === -1">
<input type="checkbox" v-model="(shapeObj as any)[key]" />
</template>
<template v-else>
<input type="checkbox" v-bind:value="value" />
</template>
</template>
<!-- Array editing -->
<template v-else-if="Array.isArray(value)">
<div style="display:flex; gap:.5rem;">
<button
@click="() => { const current = getNestedValue(shapeObj, key) || []; setNestedValue(shapeObj, key, [...current, current.length + 1]); }">Add</button>
<button
@click="() => { const current = getNestedValue(shapeObj, key) || []; if (current.length) setNestedValue(shapeObj, key, current.slice(0, -1)); }">Remove</button>
</div> </div>
<div v-else> </template>
Loading state <!-- Set editing -->
<template v-else-if="value instanceof Set">
<div style="display:flex; gap:.5rem;">
<button
@click="() => { const currentSet: Set<any> = getNestedValue(shapeObj, key); currentSet.add(`item${currentSet.size + 1}`); }">Add</button>
<button
@click="() => { const currentSet: Set<any> = getNestedValue(shapeObj, key); const last = Array.from(currentSet).pop(); if (last !== undefined) currentSet.delete(last); }">Remove</button>
</div> </div>
</template>
<template v-else>
N/A
</template>
</td>
</tr>
</tbody>
</table>
</template>
<template v-else>
<p>Loading state</p>
</template>
</div> </div>
</template> </template>

@ -1,9 +1,7 @@
import type { DeepSignalObject } from "alien-deepsignals";
import type { Diff } from "../types"; import type { Diff } from "../types";
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ /** Mock function to apply diffs. Just uses a copy of the diff as the new object. */
export function applyDiff(currentState: any, diff: Diff) { export function applyDiff(currentState: DeepSignalObject<any>, diff: Diff) {
const clone = JSON.parse(JSON.stringify(diff)); Object.assign(currentState, diff);
Object.keys(clone).forEach((k) => {
currentState[k] = clone[k];
});
} }

@ -2,23 +2,19 @@ import updateShape from "src/ng-mock/wasm-land/updateShape";
import type { Connection, Diff, Scope, Shape } from "../types"; import type { Connection, Diff, Scope, Shape } from "../types";
import requestShape from "src/ng-mock/wasm-land/requestShape"; import requestShape from "src/ng-mock/wasm-land/requestShape";
import { applyDiff } from "./applyDiff"; import { applyDiff } from "./applyDiff";
import { batch, deepSignal, watch } from "alien-deepsignals"; import { deepSignal, watch, batch } from "alien-deepsignals";
import { signal as createSignal, computed } from "alien-signals"; import { signal as createSignal } from "alien-signals";
type ReactiveShapeObject = object; type ShapeObject = {};
type ShapeObjectSignal = ReturnType< type ShapeObjectSignal = ReturnType<typeof deepSignal<ShapeObject>>;
typeof createSignal<{
content: ReactiveShapeObject | null;
}>
>;
const openConnections: Partial<Record<Shape, ShapeObjectSignal>> = {}; const openConnections: Partial<Record<Shape, ShapeObjectSignal>> = {};
/** /**
* Create a signal for a shape object. * Create a signal for a shape object.
* The function returns a signal of a shape object in the form: * The function returns a shape object that is proxied by deepSignal
* `{content: <shapeObject>}` * and keeps itself updated with the backend.
**/ **/
export function createSignalObjectForShape( export function createSignalObjectForShape(
shape: Shape, shape: Shape,
@ -27,58 +23,54 @@ export function createSignalObjectForShape(
) { ) {
if (poolSignal && openConnections[shape]) return openConnections[shape]; if (poolSignal && openConnections[shape]) return openConnections[shape];
// DeepSignal has a different API to alien-signals. // Single deepSignal root container that will hold (and become) the live shape.
// Therefore, we need to create a "root signal" wrapper that is const signalObject = deepSignal({});
// triggered on deepSignal changes.
const rootSignal = createSignal<{
content: ReactiveShapeObject | null;
}>({ content: null });
// State
let stopWatcher: any = null; let stopWatcher: any = null;
let suspendDeepWatcher = false; let suspendDeepWatcher = false;
const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => { const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => {
const rootSignalValue = rootSignal(); // eslint-disable-next-line no-console
console.log("Update received", connectionId, diff); console.debug("[shape][diff] applying", connectionId, diff);
// Set new value from applying the diffs to the old value.
suspendDeepWatcher = true; suspendDeepWatcher = true;
// We need to replace the root signal for now, so this is redundant.
batch(() => { batch(() => {
if (!rootSignalValue) return; // This shouldn't happen but we make the compiler happy. applyDiff(signalObject, diff);
const { content: proxiedShapeObj } = rootSignalValue;
applyDiff(proxiedShapeObj, diff);
// We put the proxied object into a new object for the root signal to trigger.
rootSignal({ content: proxiedShapeObj });
}); });
queueMicrotask(() => {
suspendDeepWatcher = false; suspendDeepWatcher = false;
});
}; };
// Do the actual db request. // Do the actual db request.
requestShape(shape, scope, onUpdateFromDb).then( requestShape(shape, scope, onUpdateFromDb).then(
({ connectionId, shapeObject }) => { ({ connectionId, shapeObject }) => {
// Create a deepSignal to put into the vanilla alien-signal. // Populate the root container with the initial shape (plain assignment so deepSignal wraps nested structures lazily)
const proxiedShapeObj = deepSignal(shapeObject); suspendDeepWatcher = true;
batch(() => {
Object.keys(shapeObject).forEach((k) => {
// @ts-ignore
(signalObject as any)[k] = (shapeObject as any)[k];
});
});
// Notify DB on changes. // Deep watch on the single root to propagate user edits back.
stopWatcher = watch( stopWatcher = watch(
proxiedShapeObj, signalObject,
(newVal, oldVal, onCleanup) => { (newVal) => {
// Don't update when applying changes from db diffs from the db. if (!suspendDeepWatcher) updateShape(connectionId, newVal as any);
if (!suspendDeepWatcher) updateShape(connectionId, newVal);
}, },
{ deep: true } { deep: true }
); );
// Update the root signal. queueMicrotask(() => {
rootSignal({ content: proxiedShapeObj }); suspendDeepWatcher = false;
});
} }
); );
if (poolSignal) openConnections[shape] = rootSignal; if (poolSignal) openConnections[shape] = signalObject;
// TODO: Dispose deepSignal root signal disposal. // TODO: Dispose deepSignal and stop watcher.
return rootSignal; return signalObject;
} }

@ -0,0 +1 @@
export * from "./useDeepSignal";

@ -0,0 +1,119 @@
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { subscribeDeepMutations, getDeepSignalRootId } from "alien-deepsignals";
/**
* Basic hook: re-renders whenever any deep patch for the provided deepSignal root occurs.
* Returns ONLY the deep proxy (React-like primitive hook contract).
*/
export function useDeepSignal<T extends object>(deepProxy: T): T {
const rootIdRef = useRef(getDeepSignalRootId(deepProxy as any));
const versionRef = useRef(0);
const listenersRef = useRef(new Set<() => void>());
useMemo(() => {
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootIdRef.current) return;
if (batch.some((p) => p.root === rootIdRef.current)) {
versionRef.current++;
listenersRef.current.forEach((l) => l());
}
});
return unsubscribe;
}, []);
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
}, []);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return deepProxy;
}
/** Internal representation of a tracked path as an array of keys. */
type Path = (string | number)[];
/** Convert path to a cache key string. */
const pathKey = (p: Path) => p.join("\u001f");
/**
* Selective hook: only re-renders when a patch path intersects (prefix in either direction)
* with a property path actually read during the last render. Returns a proxy view for tracking.
*/
export function useDeepSignalSelective<T extends object>(deepProxy: T): T {
const rootIdRef = useRef(getDeepSignalRootId(deepProxy as any));
const versionRef = useRef(0);
const listenersRef = useRef(new Set<() => void>());
const accessedRef = useRef<Set<string>>(new Set());
// Cache proxies per path for identity stability within a render.
const proxyCacheRef = useRef(new Map<string, any>());
// Build a tracking proxy for a target at a given path.
const buildProxy = useCallback((target: any, path: Path): any => {
const key = pathKey(path);
if (proxyCacheRef.current.has(key)) return proxyCacheRef.current.get(key);
const prox = new Proxy(target, {
get(_t, prop, recv) {
if (typeof prop === "symbol") return Reflect.get(_t, prop, recv);
const nextPath = path.concat(prop as any);
// Record full path and all prefixes for descendant change detection.
for (let i = 1; i <= nextPath.length; i++) {
accessedRef.current.add(pathKey(nextPath.slice(0, i)));
}
const val = Reflect.get(_t, prop, recv);
if (val && typeof val === "object") {
return buildProxy(val, nextPath);
}
return val;
},
});
proxyCacheRef.current.set(key, prox);
return prox;
}, []);
// Patch subscription (once)
useMemo(() => {
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootIdRef.current) return;
const relevant = batch.filter((p) => p.root === rootIdRef.current);
if (!relevant.length) return;
// Test intersection.
const used = accessedRef.current;
let hit = false;
outer: for (const patch of relevant) {
const pPath = patch.path as Path;
const pKey = pathKey(pPath);
if (used.has(pKey)) {
hit = true;
break;
}
// Check prefix/descendant
for (const usedKey of used) {
if (pKey.startsWith(usedKey) || usedKey.startsWith(pKey)) {
hit = true;
break outer;
}
}
}
if (hit) {
versionRef.current++;
listenersRef.current.forEach((l) => l());
}
});
return unsubscribe;
}, []);
const subscribe = useCallback((cb: () => void) => {
listenersRef.current.add(cb);
return () => listenersRef.current.delete(cb);
}, []);
const getSnapshot = useCallback(() => versionRef.current, []);
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
// Each render reset tracking containers so next render collects fresh usage.
accessedRef.current = new Set();
proxyCacheRef.current = new Map();
return buildProxy(deepProxy, []);
}
export default useDeepSignal;

@ -1,21 +1,16 @@
import { useSignal } from "@gn8/alien-signals-react";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types"; import type { Scope, Shape } from "src/ng-mock/js-land/types";
import useDeepSignal from "./useDeepSignal";
// TODO: The universe-signal library causes two renders on change instead of one.
// Find a fix
const useShape = (shape: Shape, scope: Scope) => { const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = useMemo(() => { const signalOfShape = useMemo(() => {
console.log("react memo called...");
return createSignalObjectForShape(shape, scope); return createSignalObjectForShape(shape, scope);
}, [shape, scope]); }, [shape, scope]);
const shapeObject = useDeepSignal(signalOfShape as unknown as object);
const [{ content: shapeObject }, setShapeObject] = useSignal(signalOfShape);
// We don't need the setter. // We don't need the setter.
// The object is recursively proxied and changes are recorded there. // The object is recursively proxied and value changes are recorded there.
return shapeObject; return shapeObject;
}; };

@ -0,0 +1,98 @@
import { derived, writable, type Readable } from "svelte/store";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
import { onDestroy } from "svelte";
import {
subscribeDeepMutations,
getDeepSignalRootId,
type DeepPatch,
} from "alien-deepsignals";
/** Base result contract for a deepSignal-backed Svelte integration. */
export interface UseDeepSignalResult<T = any> extends Readable<T> {
/** Store of the full deep proxy tree (also accessible via `subscribe` directly on this result). */
deep: Readable<T>;
/** Last batch of deep mutation patches for this root (empties only on next non-empty batch). */
patches: Readable<DeepPatch[]>;
/** Derive a nested selection; re-runs when the underlying tree version increments. */
select<U>(selector: (tree: T) => U): Readable<U>;
/** Stop receiving further updates (invoked automatically on component destroy). */
dispose(): void;
/** Replace root shape contents (mutative merge) – enables Svelte writable store binding semantics. */
set(next: Partial<T> | T): void;
/** Functional update helper using current tree snapshot. */
update(updater: (current: T) => T | void): void;
}
/**
* Generic Svelte rune bridging any deepSignal proxy root into the Svelte store contract.
*
* Exposes itself as a store (has `subscribe`) plus helper properties/methods.
*/
export function useDeepSignal<T = any>(deepProxy: T): UseDeepSignalResult<T> {
const rootId = getDeepSignalRootId(deepProxy as any);
const version = writable(0);
const patchesStore = writable<DeepPatch[]>([]);
const unsubscribe = subscribeDeepMutations((batch) => {
if (!rootId) return;
const filtered = batch.filter((p) => p.root === rootId);
if (filtered.length) {
patchesStore.set(filtered);
version.update((n) => n + 1);
}
});
const deep = derived(version, () => deepProxy);
const select = <U>(selector: (tree: T) => U): Readable<U> =>
derived(deep, (t) => selector(t));
const dispose = () => unsubscribe();
onDestroy(dispose);
// Expose Svelte store contract by delegating subscribe to deep store.
const applyReplacement = (next: any) => {
if (!next || typeof next !== "object") return;
// Remove keys absent in next
for (const k of Object.keys(deepProxy as any)) {
if (!(k in next)) delete (deepProxy as any)[k];
}
// Assign / overwrite provided keys
Object.assign(deepProxy as any, next);
};
const store: UseDeepSignalResult<T> = {
deep,
patches: patchesStore,
select,
dispose,
subscribe: deep.subscribe,
set(next) {
applyReplacement(next);
},
update(updater) {
const result = updater(deepProxy);
if (result && typeof result === "object") applyReplacement(result);
},
};
return store;
}
/** Extended result including the originating root signal wrapper from shape logic. */
export interface UseShapeRuneResult<T = any> extends UseDeepSignalResult<T> {
root: any;
}
/**
* Shape-specific rune: constructs the signal object for a shape then delegates to {@link useDeepSignal}.
*/
export function useShapeRune<T = any>(
shape: Shape,
scope?: Scope
): UseShapeRuneResult<T> {
const rootSignal = createSignalObjectForShape(shape, scope);
// rootSignal is already a deepSignal proxy root (object returned by createSignalObjectForShape)
const ds = useDeepSignal<T>(rootSignal as unknown as T);
return { root: rootSignal, ...ds } as UseShapeRuneResult<T>;
}
export default useShapeRune;

@ -1,16 +0,0 @@
import { derived } from "svelte/store";
import { useSignal } from "@gn8/alien-signals-svelte";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types";
const useShape = (shape: Shape, scope: Scope) => {
const signalOfShape = createSignalObjectForShape(shape, scope);
const writeableStoreForShape = useSignal(signalOfShape);
// Get the content "deepSignal"
const content = derived(writeableStoreForShape, (value) => value.content);
return content;
};
export default useShape;

@ -0,0 +1,26 @@
import { shallowRef, onMounted, onUnmounted } from "vue";
import { subscribeDeepMutations } from "alien-deepsignals";
/**
* Coarse-grained bridge: recompute the getter on every deep mutation batch.
* Simpler than dual dependency systems (Vue + alien) and sufficient for editor panel.
* Optimize later by filtering patches / caching accessed top-level keys.
*/
export function deepComputed<T>(getter: () => T) {
const r = shallowRef<T>(getter());
let unsubscribe: (() => void) | null = null;
onMounted(() => {
unsubscribe = subscribeDeepMutations(() => {
try {
r.value = getter();
} catch (e) {
// eslint-disable-next-line no-console
console.warn("deepComputed recompute failed", e);
}
});
});
onUnmounted(() => unsubscribe?.());
return r;
}
export default deepComputed;

@ -0,0 +1,3 @@
export * from "./useDeepSignal";
export { default as useDeepSignal } from "./useDeepSignal";
export { default as useShape } from "./useShape";

@ -0,0 +1,9 @@
import { ref } from "vue";
export function useDeepSignal<T extends object>(deepProxy: T) {
// TODO: Subscribe to and synchronize changes between deepProxy and ref.
return ref(deepProxy);
}
export default useDeepSignal;

@ -1,14 +1,16 @@
import { useSignal } from "@gn8/alien-signals-vue";
import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape";
import type { Scope, Shape } from "src/ng-mock/js-land/types"; import type { Scope, Shape } from "src/ng-mock/js-land/types";
import { computed } from "vue"; import { computed } from "vue";
const useShape = (shape: Shape, scope: Scope) => { import { useDeepSignal } from "./useDeepSignal";
const signalOfShape = createSignalObjectForShape(shape, scope);
const refOfShape = useSignal(signalOfShape);
// TODO: Maybe `refOfShape.value.content` works too? /**
return computed(() => refOfShape.value.content); * Vue adapter returning a wrapped deepSignal root whose property reads become Vue-reactive
* via a version dependency injected in the proxy layer (see useDeepSignal).
*/
const useShape = (shape: Shape, scope: Scope) => {
const container = createSignalObjectForShape(shape, scope);
// container is a deepSignal root; we only care about its content field once set.
return useDeepSignal(container) as any;
}; };
export default useShape; export default useShape;

@ -1,5 +1,5 @@
/** The shape of an object requested. */ /** The shape of an object requested. */
export type Shape = "Shape1" | "Shape2"; export type Shape = "Shape1" | "Shape2" | "TestShape";
/** The Scope of a shape request */ /** The Scope of a shape request */
export type Scope = string | string[]; export type Scope = string | string[];

@ -2,6 +2,21 @@ import * as shapeManager from "./shapeManager";
import type { WasmConnection, Diff, Scope } from "./types"; import type { WasmConnection, Diff, Scope } from "./types";
import type { Shape } from "../js-land/types"; import type { Shape } from "../js-land/types";
export const mockTestObject = {
type: "TestObject",
stringValue: "string",
numValue: 42,
boolValue: true,
nullValue: null,
arrayValue: [1, 2, 3],
objectValue: {
nestedString: "nested",
nestedNum: 7,
nestedArray: [10, 12],
},
// TODO: We can't test this right now because we serialize via JSON.
setValue: new Set(["v1", "v2", "v3"]),
};
const mockShapeObject1 = { const mockShapeObject1 = {
type: "Person", type: "Person",
name: "Bob", name: "Bob",
@ -23,6 +38,11 @@ const mockShapeObject2 = {
floor: 0, floor: 0,
}, },
}; };
const shapeNameToMockObject: Record<Shape, object> = {
Shape1: mockShapeObject1,
Shape2: mockShapeObject2,
TestShape: mockTestObject,
};
let connectionIdCounter = 1; let connectionIdCounter = 1;
@ -38,9 +58,7 @@ export default async function requestShape(
id: String(connectionIdCounter++), id: String(connectionIdCounter++),
shape, shape,
// Create a deep copy to prevent accidental by-reference changes. // Create a deep copy to prevent accidental by-reference changes.
state: JSON.parse( state: JSON.parse(JSON.stringify(shapeNameToMockObject[shape])),
JSON.stringify(shape === "Shape1" ? mockShapeObject1 : mockShapeObject2)
),
callback, callback,
}; };

@ -0,0 +1,437 @@
import { test, expect, type Page } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('https://demo.playwright.dev/todomvc');
});
const TODO_ITEMS = [
'buy some cheese',
'feed the cat',
'book a doctors appointment'
] as const;
test.describe('New Todo', () => {
test('should allow me to add todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create 1st todo.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Make sure the list only has one todo item.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0]
]);
// Create 2nd todo.
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
// Make sure the list now has two todo items.
await expect(page.getByTestId('todo-title')).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[1]
]);
await checkNumberOfTodosInLocalStorage(page, 2);
});
test('should clear text input field when an item is added', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create one todo item.
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
// Check that input is empty.
await expect(newTodo).toBeEmpty();
await checkNumberOfTodosInLocalStorage(page, 1);
});
test('should append new items to the bottom of the list', async ({ page }) => {
// Create 3 items.
await createDefaultTodos(page);
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
// Check test using different methods.
await expect(page.getByText('3 items left')).toBeVisible();
await expect(todoCount).toHaveText('3 items left');
await expect(todoCount).toContainText('3');
await expect(todoCount).toHaveText(/3/);
// Check all items in one call.
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
await checkNumberOfTodosInLocalStorage(page, 3);
});
});
test.describe('Mark all as completed', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test.afterEach(async ({ page }) => {
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should allow me to mark all items as completed', async ({ page }) => {
// Complete all todos.
await page.getByLabel('Mark all as complete').check();
// Ensure all todos have 'completed' class.
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
});
test('should allow me to clear the complete state of all items', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
// Check and then immediately uncheck.
await toggleAll.check();
await toggleAll.uncheck();
// Should be no completed classes.
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
});
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
const toggleAll = page.getByLabel('Mark all as complete');
await toggleAll.check();
await expect(toggleAll).toBeChecked();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Uncheck first todo.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').uncheck();
// Reuse toggleAll locator and make sure its not checked.
await expect(toggleAll).not.toBeChecked();
await firstTodo.getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
// Assert the toggle all is checked again.
await expect(toggleAll).toBeChecked();
});
});
test.describe('Item', () => {
test('should allow me to mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
// Check first item.
const firstTodo = page.getByTestId('todo-item').nth(0);
await firstTodo.getByRole('checkbox').check();
await expect(firstTodo).toHaveClass('completed');
// Check second item.
const secondTodo = page.getByTestId('todo-item').nth(1);
await expect(secondTodo).not.toHaveClass('completed');
await secondTodo.getByRole('checkbox').check();
// Assert completed class.
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).toHaveClass('completed');
});
test('should allow me to un-mark items as complete', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// Create two items.
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const firstTodo = page.getByTestId('todo-item').nth(0);
const secondTodo = page.getByTestId('todo-item').nth(1);
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
await firstTodoCheckbox.check();
await expect(firstTodo).toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await firstTodoCheckbox.uncheck();
await expect(firstTodo).not.toHaveClass('completed');
await expect(secondTodo).not.toHaveClass('completed');
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
});
test('should allow me to edit an item', async ({ page }) => {
await createDefaultTodos(page);
const todoItems = page.getByTestId('todo-item');
const secondTodo = todoItems.nth(1);
await secondTodo.dblclick();
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
// Explicitly assert the new text value.
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2]
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
});
test.describe('Editing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should hide other controls when editing', async ({ page }) => {
const todoItem = page.getByTestId('todo-item').nth(1);
await todoItem.dblclick();
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
await expect(todoItem.locator('label', {
hasText: TODO_ITEMS[1],
})).not.toBeVisible();
await checkNumberOfTodosInLocalStorage(page, 3);
});
test('should save edits on blur', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should trim entered text', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
'buy some sausages',
TODO_ITEMS[2],
]);
await checkTodosInLocalStorage(page, 'buy some sausages');
});
test('should remove the item if an empty text string was entered', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
await expect(todoItems).toHaveText([
TODO_ITEMS[0],
TODO_ITEMS[2],
]);
});
test('should cancel edits on escape', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).dblclick();
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
await expect(todoItems).toHaveText(TODO_ITEMS);
});
});
test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
await newTodo.fill(TODO_ITEMS[0]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('1');
await newTodo.fill(TODO_ITEMS[1]);
await newTodo.press('Enter');
await expect(todoCount).toContainText('2');
await checkNumberOfTodosInLocalStorage(page, 2);
});
});
test.describe('Clear completed button', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
});
test('should display the correct text', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
});
test('should remove completed items when clicked', async ({ page }) => {
const todoItems = page.getByTestId('todo-item');
await todoItems.nth(1).getByRole('checkbox').check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(todoItems).toHaveCount(2);
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should be hidden when there are no items that are completed', async ({ page }) => {
await page.locator('.todo-list li .toggle').first().check();
await page.getByRole('button', { name: 'Clear completed' }).click();
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
});
});
test.describe('Persistence', () => {
test('should persist its data', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS.slice(0, 2)) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
const todoItems = page.getByTestId('todo-item');
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
await firstTodoCheck.check();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
// Ensure there is 1 completed item.
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
// Now reload.
await page.reload();
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
await expect(firstTodoCheck).toBeChecked();
await expect(todoItems).toHaveClass(['completed', '']);
});
});
test.describe('Routing', () => {
test.beforeEach(async ({ page }) => {
await createDefaultTodos(page);
// make sure the app had a chance to save updated todos in storage
// before navigating to a new view, otherwise the items can get lost :(
// in some frameworks like Durandal
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
});
test('should allow me to display active items', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await expect(todoItem).toHaveCount(2);
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
});
test('should respect the back button', async ({ page }) => {
const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await test.step('Showing all items', async () => {
await page.getByRole('link', { name: 'All' }).click();
await expect(todoItem).toHaveCount(3);
});
await test.step('Showing active items', async () => {
await page.getByRole('link', { name: 'Active' }).click();
});
await test.step('Showing completed items', async () => {
await page.getByRole('link', { name: 'Completed' }).click();
});
await expect(todoItem).toHaveCount(1);
await page.goBack();
await expect(todoItem).toHaveCount(2);
await page.goBack();
await expect(todoItem).toHaveCount(3);
});
test('should allow me to display completed items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Completed' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(1);
});
test('should allow me to display all items', async ({ page }) => {
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
await page.getByRole('link', { name: 'Active' }).click();
await page.getByRole('link', { name: 'Completed' }).click();
await page.getByRole('link', { name: 'All' }).click();
await expect(page.getByTestId('todo-item')).toHaveCount(3);
});
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
//create locators for active and completed links
const activeLink = page.getByRole('link', { name: 'Active' });
const completedLink = page.getByRole('link', { name: 'Completed' });
await activeLink.click();
// Page change - active items.
await expect(activeLink).toHaveClass('selected');
await completedLink.click();
// Page change - completed items.
await expect(completedLink).toHaveClass('selected');
});
});
async function createDefaultTodos(page: Page) {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
for (const item of TODO_ITEMS) {
await newTodo.fill(item);
await newTodo.press('Enter');
}
}
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).length === e;
}, expected);
}
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
return await page.waitForFunction(e => {
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
}, expected);
}
async function checkTodosInLocalStorage(page: Page, title: string) {
return await page.waitForFunction(t => {
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
}, title);
}
Loading…
Cancel
Save