From ed6765fbf60fc95c66129b3755eb60e94974fb1f Mon Sep 17 00:00:00 2001 From: Laurin Weger Date: Mon, 25 Aug 2025 14:56:42 +0200 Subject: [PATCH] set up e2e tests and useShape/useSignal hooks --- playwright.config.ts | 8 +- src/app/components/Highlight.astro | 3 +- src/frontends/react/HelloWorld.tsx | 141 +++++- src/frontends/svelte/HelloWorld.svelte | 139 +++++- .../tests/reactiveCrossFramework.spec.ts | 171 +++++++ src/frontends/utils/flattenObject.ts | 43 ++ src/frontends/vue/HelloWorld.vue | 136 +++++- src/ng-mock/js-land/connector/applyDiff.ts | 8 +- .../connector/createSignalObjectForShape.ts | 72 ++- .../js-land/frontendAdapters/react/index.ts | 1 + .../frontendAdapters/react/useDeepSignal.ts | 119 +++++ .../frontendAdapters/react/useShape.ts | 11 +- .../svelte/useShape.svelte.ts | 98 ++++ .../frontendAdapters/svelte/useShape.ts | 16 - .../frontendAdapters/vue/deepComputed.ts | 26 ++ .../js-land/frontendAdapters/vue/index.ts | 3 + .../frontendAdapters/vue/useDeepSignal.ts | 9 + .../js-land/frontendAdapters/vue/useShape.ts | 16 +- src/ng-mock/js-land/types.ts | 2 +- src/ng-mock/wasm-land/requestShape.ts | 24 +- tests-examples/demo-todo-app.spec.ts | 437 ++++++++++++++++++ 21 files changed, 1352 insertions(+), 131 deletions(-) create mode 100644 src/frontends/utils/flattenObject.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/react/index.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts delete mode 100644 src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/vue/index.ts create mode 100644 src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts create mode 100644 tests-examples/demo-todo-app.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index b51563c..d73ec77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -34,10 +34,10 @@ export default defineConfig({ /* Configure projects for major browsers */ projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, + // { + // name: "chromium", + // use: { ...devices["Desktop Chrome"] }, + // }, { name: "firefox", diff --git a/src/app/components/Highlight.astro b/src/app/components/Highlight.astro index 4a3fb78..93001d4 100644 --- a/src/app/components/Highlight.astro +++ b/src/app/components/Highlight.astro @@ -21,7 +21,8 @@ const frameworkName = Object.keys(Astro.props)[0]; margin-left: auto; margin-right: auto; - max-width: 60%; + + min-width: 60%; } .wrap { padding: 10px; diff --git a/src/frontends/react/HelloWorld.tsx b/src/frontends/react/HelloWorld.tsx index 611037d..75296ee 100644 --- a/src/frontends/react/HelloWorld.tsx +++ b/src/frontends/react/HelloWorld.tsx @@ -1,26 +1,143 @@ import React from "react"; import useShape from "../../ng-mock/js-land/frontendAdapters/react/useShape"; +import flattenObject from "../utils/flattenObject"; export function HelloWorldReact() { - const state = useShape("Shape1", ""); + const state = useShape("TestShape", ""); + // @ts-expect-error window.reactState = state; - console.log("react render", state); + // console.log("[react] rendering", 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 (
-

Hello World from React!

- {state.name} lives at {state.address.street} -

- +

Rendered in React

+ + + + + + + + + + + {(() => { + 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]) => ( + + + + + + )); + })()} + +
KeyValueEdit
{key} + {value instanceof Set + ? Array.from(value).join(", ") + : Array.isArray(value) + ? `[${value.join(", ")}]` + : JSON.stringify(value)} + + {typeof value === "string" ? ( + { + setNestedValue(state, key, e.target.value); + }} + /> + ) : typeof value === "number" ? ( + { + setNestedValue(state, key, Number(e.target.value)); + }} + /> + ) : typeof value === "boolean" ? ( + { + setNestedValue(state, key, e.target.checked); + }} + /> + ) : Array.isArray(value) ? ( +
+ + +
+ ) : value instanceof Set ? ( +
+ + +
+ ) : ( + "N/A" + )} +
); } diff --git a/src/frontends/svelte/HelloWorld.svelte b/src/frontends/svelte/HelloWorld.svelte index 539b749..a45dc5d 100644 --- a/src/frontends/svelte/HelloWorld.svelte +++ b/src/frontends/svelte/HelloWorld.svelte @@ -1,19 +1,126 @@ - + const shapeObject = useShape("TestShape"); + + $effect(() => { + console.log("[svelte]", $shapeObject.objectValue.nestedString); + }); -
-

Hello World from Svelte!

+ 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; + } + const flatEntries = $derived( + $shapeObject ? flattenObject($shapeObject as any) : [] + ); + $effect(() => { + (window as any).svelteState = $shapeObject; + }); + -
{JSON.stringify($nestedObject, null, 4)}
- -
+{#if $shapeObject} +
+

Rendered in Svelte

+ + + + + + + + + + {#each flatEntries as [key, value] (key)} + + + + + + {/each} + +
KeyValueEdit
{key} + {#if value instanceof Set} + {Array.from(value).join(", ")} + {:else if Array.isArray(value)} + [{value.join(", ")}] + {:else} + {JSON.stringify(value)} + {/if} + + {#if typeof value === "string"} + + setNestedValue($shapeObject, key, e.target.value)} + /> + {:else if typeof value === "number"} + + setNestedValue($shapeObject, key, Number(e.target.value))} + /> + {:else if typeof value === "boolean"} + + setNestedValue($shapeObject, key, e.target.checked)} + /> + {:else if Array.isArray(value)} +
+ + +
+ {:else if value instanceof Set} +
+ + +
+ {:else} + N/A + {/if} +
+
+{:else} +

Loading state

+{/if} diff --git a/src/frontends/tests/reactiveCrossFramework.spec.ts b/src/frontends/tests/reactiveCrossFramework.spec.ts index 21f6e0b..4550602 100644 --- a/src/frontends/tests/reactiveCrossFramework.spec.ts +++ b/src/frontends/tests/reactiveCrossFramework.spec.ts @@ -1,5 +1,20 @@ 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 }) => { await page.goto("/"); 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(".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 => + 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 }); + } + } + }); + }); + } + } +}); diff --git a/src/frontends/utils/flattenObject.ts b/src/frontends/utils/flattenObject.ts new file mode 100644 index 0000000..1ba9844 --- /dev/null +++ b/src/frontends/utils/flattenObject.ts @@ -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(), + 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; diff --git a/src/frontends/vue/HelloWorld.vue b/src/frontends/vue/HelloWorld.vue index c1c3fc9..aeb0757 100644 --- a/src/frontends/vue/HelloWorld.vue +++ b/src/frontends/vue/HelloWorld.vue @@ -1,29 +1,129 @@ \ No newline at end of file diff --git a/src/ng-mock/js-land/connector/applyDiff.ts b/src/ng-mock/js-land/connector/applyDiff.ts index f5fb856..766c2cc 100644 --- a/src/ng-mock/js-land/connector/applyDiff.ts +++ b/src/ng-mock/js-land/connector/applyDiff.ts @@ -1,9 +1,7 @@ +import type { DeepSignalObject } from "alien-deepsignals"; import type { Diff } from "../types"; /** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ -export function applyDiff(currentState: any, diff: Diff) { - const clone = JSON.parse(JSON.stringify(diff)); - Object.keys(clone).forEach((k) => { - currentState[k] = clone[k]; - }); +export function applyDiff(currentState: DeepSignalObject, diff: Diff) { + Object.assign(currentState, diff); } diff --git a/src/ng-mock/js-land/connector/createSignalObjectForShape.ts b/src/ng-mock/js-land/connector/createSignalObjectForShape.ts index f2a8fb5..95b8c43 100644 --- a/src/ng-mock/js-land/connector/createSignalObjectForShape.ts +++ b/src/ng-mock/js-land/connector/createSignalObjectForShape.ts @@ -2,23 +2,19 @@ import updateShape from "src/ng-mock/wasm-land/updateShape"; import type { Connection, Diff, Scope, Shape } from "../types"; import requestShape from "src/ng-mock/wasm-land/requestShape"; import { applyDiff } from "./applyDiff"; -import { batch, deepSignal, watch } from "alien-deepsignals"; -import { signal as createSignal, computed } from "alien-signals"; +import { deepSignal, watch, batch } from "alien-deepsignals"; +import { signal as createSignal } from "alien-signals"; -type ReactiveShapeObject = object; +type ShapeObject = {}; -type ShapeObjectSignal = ReturnType< - typeof createSignal<{ - content: ReactiveShapeObject | null; - }> ->; +type ShapeObjectSignal = ReturnType>; const openConnections: Partial> = {}; /** * Create a signal for a shape object. - * The function returns a signal of a shape object in the form: - * `{content: }` + * The function returns a shape object that is proxied by deepSignal + * and keeps itself updated with the backend. **/ export function createSignalObjectForShape( shape: Shape, @@ -27,58 +23,54 @@ export function createSignalObjectForShape( ) { if (poolSignal && openConnections[shape]) return openConnections[shape]; - // DeepSignal has a different API to alien-signals. - // Therefore, we need to create a "root signal" wrapper that is - // triggered on deepSignal changes. - const rootSignal = createSignal<{ - content: ReactiveShapeObject | null; - }>({ content: null }); + // Single deepSignal root container that will hold (and become) the live shape. + const signalObject = deepSignal({}); - // State let stopWatcher: any = null; let suspendDeepWatcher = false; const onUpdateFromDb = (diff: Diff, connectionId: Connection["id"]) => { - const rootSignalValue = rootSignal(); - console.log("Update received", connectionId, diff); - // Set new value from applying the diffs to the old value. - + // eslint-disable-next-line no-console + console.debug("[shape][diff] applying", connectionId, diff); suspendDeepWatcher = true; - // We need to replace the root signal for now, so this is redundant. batch(() => { - if (!rootSignalValue) return; // This shouldn't happen but we make the compiler happy. - const { content: proxiedShapeObj } = rootSignalValue; - applyDiff(proxiedShapeObj, diff); + applyDiff(signalObject, 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. requestShape(shape, scope, onUpdateFromDb).then( ({ connectionId, shapeObject }) => { - // Create a deepSignal to put into the vanilla alien-signal. - const proxiedShapeObj = deepSignal(shapeObject); + // Populate the root container with the initial shape (plain assignment so deepSignal wraps nested structures lazily) + 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( - proxiedShapeObj, - (newVal, oldVal, onCleanup) => { - // Don't update when applying changes from db diffs from the db. - if (!suspendDeepWatcher) updateShape(connectionId, newVal); + signalObject, + (newVal) => { + if (!suspendDeepWatcher) updateShape(connectionId, newVal as any); }, { deep: true } ); - // Update the root signal. - rootSignal({ content: proxiedShapeObj }); + queueMicrotask(() => { + suspendDeepWatcher = false; + }); } ); - if (poolSignal) openConnections[shape] = rootSignal; + if (poolSignal) openConnections[shape] = signalObject; - // TODO: Dispose deepSignal root signal disposal. - return rootSignal; + // TODO: Dispose deepSignal and stop watcher. + return signalObject; } diff --git a/src/ng-mock/js-land/frontendAdapters/react/index.ts b/src/ng-mock/js-land/frontendAdapters/react/index.ts new file mode 100644 index 0000000..797c565 --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/react/index.ts @@ -0,0 +1 @@ +export * from "./useDeepSignal"; diff --git a/src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts b/src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts new file mode 100644 index 0000000..1b722d7 --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/react/useDeepSignal.ts @@ -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(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(deepProxy: T): T { + const rootIdRef = useRef(getDeepSignalRootId(deepProxy as any)); + const versionRef = useRef(0); + const listenersRef = useRef(new Set<() => void>()); + const accessedRef = useRef>(new Set()); + // Cache proxies per path for identity stability within a render. + const proxyCacheRef = useRef(new Map()); + + // 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; diff --git a/src/ng-mock/js-land/frontendAdapters/react/useShape.ts b/src/ng-mock/js-land/frontendAdapters/react/useShape.ts index fdf578e..fe10485 100644 --- a/src/ng-mock/js-land/frontendAdapters/react/useShape.ts +++ b/src/ng-mock/js-land/frontendAdapters/react/useShape.ts @@ -1,21 +1,16 @@ -import { useSignal } from "@gn8/alien-signals-react"; import { useMemo, useRef } from "react"; import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; import type { Scope, Shape } from "src/ng-mock/js-land/types"; - -// TODO: The universe-signal library causes two renders on change instead of one. -// Find a fix +import useDeepSignal from "./useDeepSignal"; const useShape = (shape: Shape, scope: Scope) => { const signalOfShape = useMemo(() => { - console.log("react memo called..."); return createSignalObjectForShape(shape, scope); }, [shape, scope]); - - const [{ content: shapeObject }, setShapeObject] = useSignal(signalOfShape); + const shapeObject = useDeepSignal(signalOfShape as unknown as object); // 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; }; diff --git a/src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts b/src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts new file mode 100644 index 0000000..a660b7c --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/svelte/useShape.svelte.ts @@ -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 extends Readable { + /** Store of the full deep proxy tree (also accessible via `subscribe` directly on this result). */ + deep: Readable; + /** Last batch of deep mutation patches for this root (empties only on next non-empty batch). */ + patches: Readable; + /** Derive a nested selection; re-runs when the underlying tree version increments. */ + select(selector: (tree: T) => U): Readable; + /** 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): 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(deepProxy: T): UseDeepSignalResult { + const rootId = getDeepSignalRootId(deepProxy as any); + const version = writable(0); + const patchesStore = writable([]); + + 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 = (selector: (tree: T) => U): Readable => + 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 = { + 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 extends UseDeepSignalResult { + root: any; +} + +/** + * Shape-specific rune: constructs the signal object for a shape then delegates to {@link useDeepSignal}. + */ +export function useShapeRune( + shape: Shape, + scope?: Scope +): UseShapeRuneResult { + const rootSignal = createSignalObjectForShape(shape, scope); + // rootSignal is already a deepSignal proxy root (object returned by createSignalObjectForShape) + const ds = useDeepSignal(rootSignal as unknown as T); + return { root: rootSignal, ...ds } as UseShapeRuneResult; +} + +export default useShapeRune; diff --git a/src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts b/src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts deleted file mode 100644 index ed85284..0000000 --- a/src/ng-mock/js-land/frontendAdapters/svelte/useShape.ts +++ /dev/null @@ -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; diff --git a/src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts b/src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts new file mode 100644 index 0000000..0e17226 --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/vue/deepComputed.ts @@ -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(getter: () => T) { + const r = shallowRef(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; diff --git a/src/ng-mock/js-land/frontendAdapters/vue/index.ts b/src/ng-mock/js-land/frontendAdapters/vue/index.ts new file mode 100644 index 0000000..4c31b09 --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/vue/index.ts @@ -0,0 +1,3 @@ +export * from "./useDeepSignal"; +export { default as useDeepSignal } from "./useDeepSignal"; +export { default as useShape } from "./useShape"; diff --git a/src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts b/src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts new file mode 100644 index 0000000..6641de2 --- /dev/null +++ b/src/ng-mock/js-land/frontendAdapters/vue/useDeepSignal.ts @@ -0,0 +1,9 @@ +import { ref } from "vue"; + +export function useDeepSignal(deepProxy: T) { + // TODO: Subscribe to and synchronize changes between deepProxy and ref. + + return ref(deepProxy); +} + +export default useDeepSignal; diff --git a/src/ng-mock/js-land/frontendAdapters/vue/useShape.ts b/src/ng-mock/js-land/frontendAdapters/vue/useShape.ts index 26cd07b..c43add0 100644 --- a/src/ng-mock/js-land/frontendAdapters/vue/useShape.ts +++ b/src/ng-mock/js-land/frontendAdapters/vue/useShape.ts @@ -1,14 +1,16 @@ -import { useSignal } from "@gn8/alien-signals-vue"; import { createSignalObjectForShape } from "src/ng-mock/js-land/connector/createSignalObjectForShape"; import type { Scope, Shape } from "src/ng-mock/js-land/types"; import { computed } from "vue"; -const useShape = (shape: Shape, scope: Scope) => { - const signalOfShape = createSignalObjectForShape(shape, scope); - - const refOfShape = useSignal(signalOfShape); +import { useDeepSignal } from "./useDeepSignal"; - // 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; diff --git a/src/ng-mock/js-land/types.ts b/src/ng-mock/js-land/types.ts index accb677..c794e3f 100644 --- a/src/ng-mock/js-land/types.ts +++ b/src/ng-mock/js-land/types.ts @@ -1,5 +1,5 @@ /** The shape of an object requested. */ -export type Shape = "Shape1" | "Shape2"; +export type Shape = "Shape1" | "Shape2" | "TestShape"; /** The Scope of a shape request */ export type Scope = string | string[]; diff --git a/src/ng-mock/wasm-land/requestShape.ts b/src/ng-mock/wasm-land/requestShape.ts index c739145..98665a1 100644 --- a/src/ng-mock/wasm-land/requestShape.ts +++ b/src/ng-mock/wasm-land/requestShape.ts @@ -2,6 +2,21 @@ import * as shapeManager from "./shapeManager"; import type { WasmConnection, Diff, Scope } from "./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 = { type: "Person", name: "Bob", @@ -23,6 +38,11 @@ const mockShapeObject2 = { floor: 0, }, }; +const shapeNameToMockObject: Record = { + Shape1: mockShapeObject1, + Shape2: mockShapeObject2, + TestShape: mockTestObject, +}; let connectionIdCounter = 1; @@ -38,9 +58,7 @@ export default async function requestShape( id: String(connectionIdCounter++), shape, // Create a deep copy to prevent accidental by-reference changes. - state: JSON.parse( - JSON.stringify(shape === "Shape1" ? mockShapeObject1 : mockShapeObject2) - ), + state: JSON.parse(JSON.stringify(shapeNameToMockObject[shape])), callback, }; diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts new file mode 100644 index 0000000..8641cb5 --- /dev/null +++ b/tests-examples/demo-todo-app.spec.ts @@ -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); +}