From 7d35c640fec47145193e753cfffb0184745d756a Mon Sep 17 00:00:00 2001 From: Mantra Date: Thu, 19 Dec 2024 01:04:04 +0000 Subject: [PATCH 01/17] tests done --- app/server/src/static-site-builder/builder.ts | 43 ++++++ .../src/static-site-builder/wodinBuilder.ts | 48 +------ .../tests/static-site-builder/args.test.ts | 29 ++++ .../tests/static-site-builder/builder.test.ts | 90 +++++++++++++ .../static-site-builder/tempDirTestHelper.ts | 22 +++ app/server/vitest/vitest.unit.config.mjs | 2 +- app/static/src/externalScriptSrc.ts | 2 +- app/static/src/mainUtils.ts | 18 --- app/static/src/wodin-static.ts | 106 --------------- app/static/src/wodin.ts | 2 +- app/static/src/wodinStatic.ts | 49 +++++++ app/static/src/wodinStaticUtils.ts | 110 +++++++++++++++ .../tests/unit/externalScriptSrc.test.ts | 12 ++ .../tests/unit/wodinStaticUtils.test.ts | 125 ++++++++++++++++++ app/static/vite-static.config.ts | 2 +- app/static/vitest.config.ts | 2 +- 16 files changed, 487 insertions(+), 175 deletions(-) create mode 100644 app/server/src/static-site-builder/builder.ts create mode 100644 app/server/tests/static-site-builder/args.test.ts create mode 100644 app/server/tests/static-site-builder/builder.test.ts create mode 100644 app/server/tests/static-site-builder/tempDirTestHelper.ts delete mode 100644 app/static/src/mainUtils.ts delete mode 100644 app/static/src/wodin-static.ts create mode 100644 app/static/src/wodinStatic.ts create mode 100644 app/static/src/wodinStaticUtils.ts create mode 100644 app/static/tests/unit/externalScriptSrc.test.ts create mode 100644 app/static/tests/unit/wodinStaticUtils.test.ts diff --git a/app/server/src/static-site-builder/builder.ts b/app/server/src/static-site-builder/builder.ts new file mode 100644 index 00000000..e60f5ce5 --- /dev/null +++ b/app/server/src/static-site-builder/builder.ts @@ -0,0 +1,43 @@ +import path from "path"; +import fs from "fs"; +import { readFile } from "../configReader"; +import axios from "axios"; + +export const buildWodinStaticSite = async (configPath: string, destPath: string) => { + const storesPath = path.resolve(configPath, "stores") + const stores = fs.readdirSync(storesPath, { recursive: false }) as string[]; + + // bootstrapping and making sure the folder structure is correct + if (!fs.existsSync(destPath)) { + fs.mkdirSync(destPath); + } + const destStoresPath = path.resolve(destPath, "stores"); + if (fs.existsSync(destStoresPath)) { + fs.rmSync(destStoresPath, { recursive: true }); + } + fs.mkdirSync(destStoresPath); + + // copy in their config specific files (assets to come soon) + fs.cpSync(path.resolve(configPath, "index.html"), path.resolve(destPath, "index.html")); + + // get runners + const runnerOdeResponse = await axios.get("http://localhost:8001/support/runner-ode"); + fs.writeFileSync(path.resolve(destStoresPath, "runnerOde.js"), runnerOdeResponse.data.data); + const runnerDiscreteResponse = await axios.get("http://localhost:8001/support/runner-discrete"); + fs.writeFileSync(path.resolve(destStoresPath, "runnerDiscrete.js"), runnerDiscreteResponse.data.data); + + // make folder per store with config.json (their config + default code) and + // model.json (model response from odin.api) + stores.forEach(async store => { + const destStorePath = path.resolve(destStoresPath, store); + fs.mkdirSync(destStorePath); + const model = readFile(path.resolve(storesPath, store, "model.R")).split("\n"); + + const config = JSON.parse(readFile(path.resolve(storesPath, store, "config.json"))) as { appType: string }; + fs.writeFileSync(path.resolve(destStorePath, "config.json"), JSON.stringify({ ...config, defaultCode: model })); + + const timeType = config.appType === "stochastic" ? "discrete" : "continuous"; + const modelResponse = await axios.post("http://localhost:8001/compile", { model, requirements: { timeType } }); + fs.writeFileSync(path.resolve(destStorePath, `model.json`), JSON.stringify(modelResponse.data.data)); + }); +}; diff --git a/app/server/src/static-site-builder/wodinBuilder.ts b/app/server/src/static-site-builder/wodinBuilder.ts index 6cb0ca2e..f8e09f99 100644 --- a/app/server/src/static-site-builder/wodinBuilder.ts +++ b/app/server/src/static-site-builder/wodinBuilder.ts @@ -1,50 +1,6 @@ -import path from "path"; -import fs from "fs"; -import { readFile } from "../configReader"; -import axios from "axios"; import { processArgs } from "./args"; - -const mkdirForce = (path: string) => { - if (!fs.existsSync(path)) { - fs.mkdirSync(path); - } -}; +import { buildWodinStaticSite } from "./builder"; const { configPath, destPath } = processArgs(); -const storesPath = path.resolve(configPath, "stores") -const stores = fs.readdirSync(storesPath, { recursive: false }) as string[]; - -mkdirForce(destPath); -const destStoresPath = path.resolve(destPath, "stores"); -if (fs.existsSync(destStoresPath)) { - fs.rmSync(destStoresPath, { recursive: true }); -} -fs.mkdirSync(destStoresPath); - -fs.cpSync(path.resolve(configPath, "index.html"), path.resolve(destPath, "index.html")); - -const getRunners = async () => { - const runnerOdeResponse = await axios.get("http://localhost:8001/support/runner-ode"); - fs.writeFileSync(path.resolve(destStoresPath, "runnerOde.js"), runnerOdeResponse.data.data); - - const runnerDiscreteResponse = await axios.get("http://localhost:8001/support/runner-discrete"); - fs.writeFileSync(path.resolve(destStoresPath, "runnerDiscrete.js"), runnerDiscreteResponse.data.data); -} -getRunners(); - -stores.forEach(async store => { - const destStorePath = path.resolve(destStoresPath, store); - fs.mkdirSync(destStorePath); - - const model = readFile(path.resolve(storesPath, store, "model.R")).split("\n"); - - const config = JSON.parse(readFile(path.resolve(storesPath, store, "config.json"))) as { appType: string }; - fs.writeFileSync(path.resolve(destStorePath, "config.json"), JSON.stringify({ ...config, defaultCode: model })); - - const modelResponse = await axios.post("http://localhost:8001/compile", { - model, - requirements: { timeType: config.appType === "stochastic" ? "discrete" : "continuous" } - }); - fs.writeFileSync(path.resolve(destStorePath, `model.json`), JSON.stringify(modelResponse.data.data)); -}); +buildWodinStaticSite(configPath, destPath); diff --git a/app/server/tests/static-site-builder/args.test.ts b/app/server/tests/static-site-builder/args.test.ts new file mode 100644 index 00000000..b8ffb645 --- /dev/null +++ b/app/server/tests/static-site-builder/args.test.ts @@ -0,0 +1,29 @@ +import { processArgs } from "../../src/static-site-builder/args"; + +describe("args", () => { + const realArgs = process.argv; + afterEach(() => { + process.argv = realArgs; + }); + + const builderPath = "/builder", + cfgPath = "/configPath", + destPath = "/destPath"; + + it("throws error if no config path or dest path arg", () => { + const argv = ["node", builderPath]; + expect(() => { processArgs(argv); }) + .toThrow("Usage:"); + + const argv1 = ["node", builderPath, cfgPath]; + expect(() => { processArgs(argv1); }) + .toThrow("Usage:"); + }); + + it("returns config path and dest path", () => { + const argv = ["node", builderPath, cfgPath, destPath]; + const args = processArgs(argv); + expect(args.configPath).toBe(cfgPath); + expect(args.destPath).toBe(destPath); + }); +}); diff --git a/app/server/tests/static-site-builder/builder.test.ts b/app/server/tests/static-site-builder/builder.test.ts new file mode 100644 index 00000000..d6571c5e --- /dev/null +++ b/app/server/tests/static-site-builder/builder.test.ts @@ -0,0 +1,90 @@ +import path from "path"; +import fs from "fs"; +import { tmpdirTest } from "./tempDirTestHelper" +import { buildWodinStaticSite } from "../../src/static-site-builder/builder" + +const buildSite = async (tmpdir: string) => { + await buildWodinStaticSite("../../config-static", tmpdir); +} + +const { + mockRunnerOde, mockRunnerDiscrete, + mockModel, mockGet, mockPost +} = vi.hoisted(() => { + const mockRunnerOde = "var odinRunnerOde;"; + const mockRunnerDiscrete = "var odinRunnerDiscrete;"; + const mockModel = "class odinModel {};"; + const mockGet = vi.fn().mockImplementation((url: string) => { + if (url === "http://localhost:8001/support/runner-ode") { + return { data: { data: mockRunnerOde } }; + } else if (url === "http://localhost:8001/support/runner-discrete") { + return { data: { data: mockRunnerDiscrete } }; + } + }); + const mockPost = vi.fn().mockImplementation((url: string) => { + if (url === "http://localhost:8001/compile") { + return { data: { data: mockModel } }; + } + }); + return { + mockRunnerOde, mockRunnerDiscrete, + mockModel, mockGet, mockPost + }; +}); + +vi.mock("axios", () => { + return { + default: { + get: mockGet, + post: mockPost + } + } +}); + +const p = path.resolve; + +const expectPath = (...paths: string[]) => { + expect(fs.existsSync(p(...paths))).toBe(true); +}; + +const expectPathContent = (content: string, ...paths: string[]) => { + expect(fs.readFileSync(p(...paths)).toString()).toBe(content); +} + +const expectPathContentContains = (content: string, ...paths: string[]) => { + expect(fs.readFileSync(p(...paths)).toString()).toContain(content); +} + +describe("Wodin builder", () => { + tmpdirTest("creates dest dir if it doesn't exist", async ({ tmpdir }) => { + fs.rmdirSync(tmpdir); + await buildSite(tmpdir); + expectPath(tmpdir); + }); + + tmpdirTest("works as expected", async ({ tmpdir }) => { + const storesPath = p(tmpdir, "stores"); + await buildSite(tmpdir); + + expectPath(tmpdir, "index.html"); + + expectPath(storesPath, "runnerOde.js"); + expectPathContent(mockRunnerOde, storesPath, "runnerOde.js"); + expectPath(storesPath, "runnerDiscrete.js"); + expectPathContent(mockRunnerDiscrete, storesPath, "runnerDiscrete.js"); + + expectPath(storesPath, "basic", "model.json"); + expectPathContentContains(mockModel, storesPath, "basic", "model.json"); + expectPath(storesPath, "basic", "config.json"); + }); + + tmpdirTest("overwrites existing stores folder", async ({ tmpdir }) => { + const storesPath = p(tmpdir, "stores"); + fs.mkdirSync(storesPath); + fs.writeFileSync(p(storesPath, "trash.txt"), "whats up"); + + await buildSite(tmpdir); + + expect(fs.existsSync(p(storesPath, "trash.txt"))).toBe(false) + }); +}) \ No newline at end of file diff --git a/app/server/tests/static-site-builder/tempDirTestHelper.ts b/app/server/tests/static-site-builder/tempDirTestHelper.ts new file mode 100644 index 00000000..5f56d5e4 --- /dev/null +++ b/app/server/tests/static-site-builder/tempDirTestHelper.ts @@ -0,0 +1,22 @@ +import { test } from "vitest"; +import os from "node:os"; +import path from "path"; +import fs from "fs"; + +type TmpDirTestFixture = { tmpdir: string; } + +const createTempDir = () => { + const ostmpdir = os.tmpdir(); + const tmpdir = path.resolve(ostmpdir, "static-site-builder-tests-"); + return fs.mkdtempSync(tmpdir); +}; + +export const tmpdirTest = test.extend({ + // eslint-disable-next-line no-empty-pattern + tmpdir: async ({}, use) => { + const dir = createTempDir(); + await use(dir); + + fs.rmSync(dir, { recursive: true }) + } +}); diff --git a/app/server/vitest/vitest.unit.config.mjs b/app/server/vitest/vitest.unit.config.mjs index e4a0aef9..ca1fd2b4 100644 --- a/app/server/vitest/vitest.unit.config.mjs +++ b/app/server/vitest/vitest.unit.config.mjs @@ -9,7 +9,7 @@ export default mergeConfig( coverage: { provider: "istanbul", include: ["src"], - exclude: ["**/tests/**", "src/server/server.ts"] + exclude: ["**/tests/**", "src/server/server.ts", "src/static-site-builder/wodinBuilder.ts"] } } }) diff --git a/app/static/src/externalScriptSrc.ts b/app/static/src/externalScriptSrc.ts index 60cd4ed8..e5f46723 100644 --- a/app/static/src/externalScriptSrc.ts +++ b/app/static/src/externalScriptSrc.ts @@ -1,4 +1,4 @@ -const externalScripts = [ +export const externalScripts = [ "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" ]; diff --git a/app/static/src/mainUtils.ts b/app/static/src/mainUtils.ts deleted file mode 100644 index c2971265..00000000 --- a/app/static/src/mainUtils.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AppType } from "./store/appState/state"; -import { storeOptions as basicStoreOptions } from "./store/basic/basic"; -import { storeOptions as fitStoreOptions } from "./store/fit/fit"; -import { storeOptions as stochasticStoreOptions } from "./store/stochastic/stochastic"; - -const { Basic, Fit, Stochastic } = AppType; -export const getStoreOptions = (appType: AppType) => { - switch (appType) { - case Basic: - return basicStoreOptions; - case Fit: - return fitStoreOptions; - case Stochastic: - return stochasticStoreOptions; - default: - throw new Error("Unknown app type"); - } -}; diff --git a/app/static/src/wodin-static.ts b/app/static/src/wodin-static.ts deleted file mode 100644 index 95da8907..00000000 --- a/app/static/src/wodin-static.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { createApp } from "vue"; -import { Store, StoreOptions } from "vuex"; -import axios from "axios"; -import "./scss/style.scss" -import { AppConfig, OdinModelResponse, OdinRunnerDiscrete, OdinRunnerOde } from "./types/responseTypes"; -import { AppState, AppType } from "./store/appState/state"; -import { AppStateMutation } from "./store/appState/mutations"; -import { ModelMutation } from "./store/model/mutations"; -import { RunAction } from "./store/run/actions"; -import { ModelAction } from "./store/model/actions"; -import { getStoreOptions } from "./mainUtils"; -import { mountScriptTags } from "./externalScriptSrc"; -import RunTab from "./components/run/RunTab.vue"; -import SensitivityTab from "./components/sensitivity/SensitivityTab.vue"; - -declare let odinjs: OdinRunnerOde -declare let dust: OdinRunnerDiscrete - -const blockingScripts = ["./stores/runnerOde.js", "./stores/runnerDiscrete.js"]; - -const deepCopyStateIfExists = (obj: T) => { - if (obj.state) obj.state = JSON.parse(JSON.stringify(obj.state)); -} - -const componentsAndSelectors = (s: string) => ([ - { selector: `.w-run-graph[data-w-store="${s}"]`, component: RunTab }, - { selector: `.w-sens-graph[data-w-store="${s}"]`, component: SensitivityTab } -]); - -const boot = async () => { - // inject external scripts - mountScriptTags(); - - // inject internal runner scripts - let numScriptsLoaded = 0; - blockingScripts.forEach(src => { - const script = document.createElement("script"); - script.async = false; - script.src = src; - document.body.append(script); - script.onload = () => numScriptsLoaded++; - }); - - // wait for runner scripts to load (waiting for correct number of - // onload events from script tags above) - await new Promise(res => setInterval(() => { - if (numScriptsLoaded === blockingScripts.length) return res(null) - }, 100)); - - // find all stores the user has put in the page - const stores = document.querySelectorAll("[data-w-store]")!; - const storesInPage: string[] = [] - stores.forEach(el => { - const elStore = el.getAttribute("data-w-store")!; - if (!storesInPage.includes(elStore)) { - storesInPage.push(elStore); - } - }); - - // get relevant store configs and model code - const configPromises = storesInPage.map(s => axios.get(`./stores/${s}/config.json`)); - const modelResponsePromises = storesInPage.map(s => axios.get(`./stores/${s}/model.json`)); - const configs = (await Promise.all(configPromises)).map(res => res.data) as { appType: AppType, defaultCode: string[] }[]; - const modelResponses = (await Promise.all(modelResponsePromises)).map(res => res.data) as OdinModelResponse[]; - - storesInPage.forEach(async (s, i) => { - const { appType, defaultCode } = configs[i]; - const modelResponse = modelResponses[i]; - const originalStore = getStoreOptions(appType) as StoreOptions; - - // recursively deep copy state because without this we would have multiple - // stores writing to the same state objects - const deepCopyStore = { ...originalStore }; - deepCopyStateIfExists(deepCopyStore); - Object.keys(deepCopyStore.modules!).forEach(m => deepCopyStateIfExists(deepCopyStore.modules![m])); - - const store = new Store(deepCopyStore); - - // manually initialise store - const appConfigPayload = { - appType, - basicProp: "", - defaultCode, - endTime: 100, - readOnlyCode: true, - stateUploadIntervalMillis: 2_000_000, - maxReplicatesRun: 100, - maxReplicatesDisplay: 50 - } as AppConfig - store.commit(AppStateMutation.SetConfig, appConfigPayload); - store.commit(`model/${ModelMutation.SetOdinRunnerOde}`, odinjs); - store.commit(`model/${ModelMutation.SetOdinRunnerDiscrete}`, dust); - store.commit(`model/${ModelMutation.SetOdinResponse}`, modelResponse); - await store.dispatch(`model/${ModelAction.CompileModel}`) - await store.dispatch(`run/${RunAction.RunModel}`) - - // mount components to dom elements based on selectors - componentsAndSelectors(s).forEach(({ component, selector }) => { - const applet = createApp(component); - applet.use(store); - document.querySelectorAll(selector)?.forEach(el => applet.mount(el)); - }); - }) -}; - -boot() diff --git a/app/static/src/wodin.ts b/app/static/src/wodin.ts index 35cd0ef7..470251e2 100644 --- a/app/static/src/wodin.ts +++ b/app/static/src/wodin.ts @@ -10,7 +10,7 @@ import "./assets/fontawesome.css"; import "./scss/style.scss" import help from "./directives/help"; import App from "./components/App.vue"; -import { getStoreOptions } from "./mainUtils"; +import { getStoreOptions } from "./wodinStaticUtils"; import { Store, StoreOptions } from "vuex"; import { mountScriptTags } from "./externalScriptSrc"; diff --git a/app/static/src/wodinStatic.ts b/app/static/src/wodinStatic.ts new file mode 100644 index 00000000..dc8d9610 --- /dev/null +++ b/app/static/src/wodinStatic.ts @@ -0,0 +1,49 @@ +import { createApp } from "vue"; +import { getStoreOptions } from "./wodinStaticUtils"; +import { Store, StoreOptions } from "vuex"; +import "./scss/style.scss" +import { AppState } from "./store/appState/state"; +import { mountScriptTags } from "./externalScriptSrc"; +import { + componentsAndSelectors, getConfigsAndModels, getDeepCopiedStoreOptions, + getStoresInPage, initialiseStore, waitForBlockingScripts +} from "./wodinStaticUtils"; + +const blockingScripts = ["./stores/runnerOde.js", "./stores/runnerDiscrete.js"]; + +const boot = async () => { + // inject external scripts such as mathjax + mountScriptTags(); + + // wait for scripts we can't load without such as runner code + await waitForBlockingScripts(blockingScripts); + + // find all stores the user has put in the page + const storesInPage = getStoresInPage(); + + // get relevant store configs and model code + const { configs, modelResponses } = await getConfigsAndModels(storesInPage); + + storesInPage.forEach(async (s, i) => { + const { appType, defaultCode } = configs[i]; + const modelResponse = modelResponses[i]; + const originalStoreOptions = getStoreOptions(appType) as StoreOptions; + + // recursively deep copy state because without this we would have multiple + // stores writing to the same state objects + const store = new Store(getDeepCopiedStoreOptions(originalStoreOptions)); + + // manually initialise store because this is the responsibility of apps like + // WodinSession which aren't used in the static build + await initialiseStore(store, appType, defaultCode, modelResponse); + + // mount components to dom elements based on selectors + componentsAndSelectors(s).forEach(({ component, selector }) => { + const applet = createApp(component); + applet.use(store); + document.querySelectorAll(selector)?.forEach(el => applet.mount(el)); + }); + }) +}; + +boot() diff --git a/app/static/src/wodinStaticUtils.ts b/app/static/src/wodinStaticUtils.ts new file mode 100644 index 00000000..52dd55ca --- /dev/null +++ b/app/static/src/wodinStaticUtils.ts @@ -0,0 +1,110 @@ +import axios from "axios"; +import RunTab from "./components/run/RunTab.vue"; +import SensitivityTab from "./components/sensitivity/SensitivityTab.vue"; +import { AppState, AppType } from "./store/appState/state"; +import { AppConfig, OdinModelResponse, OdinRunnerDiscrete, OdinRunnerOde } from "./types/responseTypes"; +import { Store, StoreOptions } from "vuex"; +import { AppStateMutation } from "./store/appState/mutations"; +import { ModelMutation } from "./store/model/mutations"; +import { ModelAction } from "./store/model/actions"; +import { RunAction } from "./store/run/actions"; +import { storeOptions as basicStoreOptions } from "./store/basic/basic"; +import { storeOptions as fitStoreOptions } from "./store/fit/fit"; +import { storeOptions as stochasticStoreOptions } from "./store/stochastic/stochastic"; + +const { Basic, Fit, Stochastic } = AppType; +export const getStoreOptions = (appType: AppType) => { + switch (appType) { + case Basic: + return basicStoreOptions; + case Fit: + return fitStoreOptions; + case Stochastic: + return stochasticStoreOptions; + default: + throw new Error("Unknown app type"); + } +}; + +export const componentsAndSelectors = (s: string) => ([ + { selector: `.w-run-graph[data-w-store="${s}"]`, component: RunTab }, + { selector: `.w-sens-graph[data-w-store="${s}"]`, component: SensitivityTab } +]); + +export const waitForBlockingScripts = async (blockingScripts: string[]) => { + // inject internal runner scripts + let numScriptsLoaded = 0; + blockingScripts.forEach(src => { + const script = document.createElement("script"); + script.async = false; + script.src = src; + document.body.append(script); + script.onload = () => numScriptsLoaded++; + }); + + // wait for runner scripts to load (waiting for correct number of + // onload events from script tags above) + await new Promise(res => setInterval(() => { + if (numScriptsLoaded === blockingScripts.length) return res(null) + }, 100)); +}; + +export const getStoresInPage = () => { + const stores = document.querySelectorAll("[data-w-store]")!; + const storesInPage: string[] = [] + stores.forEach(el => { + const elStore = el.getAttribute("data-w-store")!; + if (!storesInPage.includes(elStore)) { + storesInPage.push(elStore); + } + }); + return storesInPage; +}; + +export const getConfigsAndModels = async (storesInPage: string[]) => { + const configPromises = storesInPage.map(s => axios.get(`./stores/${s}/config.json`)); + const modelResponsePromises = storesInPage.map(s => axios.get(`./stores/${s}/model.json`)); + const configs = (await Promise.all(configPromises)).map(res => res.data) as { appType: AppType, defaultCode: string[] }[]; + const modelResponses = (await Promise.all(modelResponsePromises)).map(res => res.data) as OdinModelResponse[]; + return { configs, modelResponses }; +}; + +export const getDeepCopiedStoreOptions = (storeOptions: StoreOptions) => { + const deepCopy = { ...storeOptions }; + if (deepCopy.state) { + deepCopy.state = JSON.parse(JSON.stringify(deepCopy.state)); + } + deepCopy.modules = { ...deepCopy.modules }; + Object.keys(deepCopy.modules!).forEach(m => { + deepCopy.modules![m] = { ...deepCopy.modules![m] }; + if (deepCopy.modules![m].state) { + deepCopy.modules![m].state = JSON.parse(JSON.stringify(deepCopy.modules![m].state)); + } + }); + return deepCopy; +}; + +declare let odinjs: OdinRunnerOde +declare let dust: OdinRunnerDiscrete + +export const initialiseStore = async ( + store: Store, appType: AppType, + defaultCode: string[], modelResponse: OdinModelResponse +) => { + const appConfigPayload = { + appType, + basicProp: "", + defaultCode, + endTime: 100, + readOnlyCode: true, + stateUploadIntervalMillis: 2_000_000, + maxReplicatesRun: 100, + maxReplicatesDisplay: 50 + } as AppConfig + store.commit(AppStateMutation.SetConfig, appConfigPayload); + store.commit(`model/${ModelMutation.SetOdinRunnerOde}`, odinjs); + store.commit(`model/${ModelMutation.SetOdinRunnerDiscrete}`, dust); + store.commit(`model/${ModelMutation.SetOdinResponse}`, modelResponse); + await store.dispatch(`model/${ModelAction.CompileModel}`) + await store.dispatch(`run/${RunAction.RunModel}`) +}; diff --git a/app/static/tests/unit/externalScriptSrc.test.ts b/app/static/tests/unit/externalScriptSrc.test.ts new file mode 100644 index 00000000..8d707594 --- /dev/null +++ b/app/static/tests/unit/externalScriptSrc.test.ts @@ -0,0 +1,12 @@ +import { externalScripts, mountScriptTags } from "../../src/externalScriptSrc" + +describe("external script src", () => { + test("mounts enternal scripts to document", () => { + mountScriptTags(); + externalScripts.forEach((src, i) => { + const script = document.body.getElementsByTagName("script").item(i) + expect(script!.src).toBe(src); + expect(script!.defer).toBe(true); + }) + }); +}); diff --git a/app/static/tests/unit/wodinStaticUtils.test.ts b/app/static/tests/unit/wodinStaticUtils.test.ts new file mode 100644 index 00000000..41986951 --- /dev/null +++ b/app/static/tests/unit/wodinStaticUtils.test.ts @@ -0,0 +1,125 @@ +import { AppType } from "@/store/appState/state"; +import { componentsAndSelectors, getStoreOptions, waitForBlockingScripts, getStoresInPage, getConfigsAndModels, getDeepCopiedStoreOptions, initialiseStore } from "@/wodinStaticUtils"; +import { storeOptions as basicStoreOptions } from "@/store/basic/basic"; +import { storeOptions as fitStoreOptions } from "@/store/fit/fit"; +import { storeOptions as stochasticStoreOptions } from "@/store/stochastic/stochastic"; +import { AppStateMutation } from "@/store/appState/mutations"; +import { ModelMutation } from "@/store/model/mutations"; +import { ModelAction } from "@/store/model/actions"; +import { RunAction } from "@/store/run/actions"; + +const { mockConfigRes, mockModelRes, mockGet } = vi.hoisted(() => { + const mockConfigRes = (s: string) => `config-${s}`; + const mockModelRes = (s: string) => `model-${s}`; + const mockGet = vi.fn().mockImplementation((url: string) => { + const store = url.split("/").at(-2); + const type = url.split("/").at(-1) === "config.json" ? "config" : "model"; + return { data: `${type}-${store}` }; + }); + return { mockConfigRes, mockModelRes, mockGet }; +}); +vi.mock("axios", () => ({ default: { get: mockGet } })); + +describe("wodin static utils", () => { + test("can get correct store options", () => { + const b = getStoreOptions(AppType.Basic); + expect(b).toStrictEqual(basicStoreOptions); + + const f = getStoreOptions(AppType.Fit); + expect(f).toStrictEqual(fitStoreOptions); + + const s = getStoreOptions(AppType.Stochastic); + expect(s).toStrictEqual(stochasticStoreOptions); + }); + + test("component and selector interpolates store correctly", () => { + const { selector } = componentsAndSelectors("test-store")[0]; + expect(selector).toContain(`data-w-store="test-store"`); + }); + + test("can wait for blocking scripts", async () => { + let promiseDone = false; + const mockScript = { async: true, src: "", onload: null }; + const createElementSpy = vi.spyOn(document, "createElement") + .mockImplementation(() => mockScript as any); + const documentBodySpy = vi.spyOn(document.body, "append"); + + waitForBlockingScripts(["test-script"]).then(() => promiseDone = true); + (mockScript.onload as any)(); + await vi.waitFor(() => expect(promiseDone).toBe(true)); + + expect(createElementSpy).toBeCalledTimes(1); + expect((documentBodySpy.mock.calls[0][0] as any).async).toBe(false); + expect((documentBodySpy.mock.calls[0][0] as any).src).toBe("test-script"); + }); + + test("get stores in page returns unique list of stores", async () => { + const testStores = ["store1", "store1", "store2"]; + vi.spyOn(document, "querySelectorAll").mockImplementation(() => { + return testStores.map(s => ({ getAttribute: vi.fn().mockImplementation(() => s) })) as any + }); + const storesInPage = getStoresInPage(); + expect(storesInPage).toStrictEqual(["store1", "store2"]); + }); + + test("can get configs and models for stores", async () => { + const testStores = ["store1", "store2"]; + const { configs, modelResponses } = await getConfigsAndModels(testStores); + configs.forEach((c, i) => expect(c).toBe(mockConfigRes(testStores[i]))); + modelResponses.forEach((m, i) => expect(m).toBe(mockModelRes(testStores[i]))); + }); + + test("can deep copy store options", () => { + const mockStoreOptions = { + state: { key1: "hey1" }, + modules: { + mod1: { state: { key2: "hey2" } }, + mod2: { state: { key3: "hey3" } } + } + }; + const copy = getDeepCopiedStoreOptions(mockStoreOptions as any) as any; + copy.state.key1 = "what1"; + copy.modules.mod1.state.key2 = "what2"; + copy.modules.mod2.state.key3 = "what3"; + expect(mockStoreOptions.state.key1).toBe("hey1"); + expect(mockStoreOptions.modules.mod1.state.key2).toBe("hey2"); + expect(mockStoreOptions.modules.mod2.state.key3).toBe("hey3"); + }); + + test("can initialise store", async () => { + (globalThis as any).odinjs = "test odinjs"; + (globalThis as any).dust = "test dust"; + + const commit = vi.fn(); + const dispatch = vi.fn(); + await initialiseStore( + { commit, dispatch } as any, AppType.Basic, + ["test", "code"], "test model res" as any + ); + + expect(commit).toBeCalledTimes(4); + + expect(commit.mock.calls[0][0]).toBe(AppStateMutation.SetConfig); + expect(commit.mock.calls[0][1]).toStrictEqual({ + appType: AppType.Basic, + basicProp: "", + defaultCode: ["test", "code"], + endTime: 100, + readOnlyCode: true, + stateUploadIntervalMillis: 2_000_000, + maxReplicatesRun: 100, + maxReplicatesDisplay: 50 + }); + expect(commit.mock.calls[1][0]).toBe(`model/${ModelMutation.SetOdinRunnerOde}`); + expect(commit.mock.calls[1][1]).toBe("test odinjs"); + expect(commit.mock.calls[2][0]).toBe(`model/${ModelMutation.SetOdinRunnerDiscrete}`); + expect(commit.mock.calls[2][1]).toBe("test dust"); + expect(commit.mock.calls[3][0]).toBe(`model/${ModelMutation.SetOdinResponse}`); + expect(commit.mock.calls[3][1]).toBe("test model res"); + + expect(dispatch).toBeCalledTimes(2); + + expect(dispatch.mock.calls[0][0]).toBe(`model/${ModelAction.CompileModel}`); + expect(dispatch.mock.calls[1][0]).toBe(`run/${RunAction.RunModel}`); + }); +}); diff --git a/app/static/vite-static.config.ts b/app/static/vite-static.config.ts index 28fc7343..c9999887 100644 --- a/app/static/vite-static.config.ts +++ b/app/static/vite-static.config.ts @@ -6,7 +6,7 @@ import config from "./vite-common.config" export default mergeConfig(config, { build: { rollupOptions: { - input: resolve(__dirname, './src/wodin-static.ts') + input: resolve(__dirname, './src/wodinStatic.ts') } } }); diff --git a/app/static/vitest.config.ts b/app/static/vitest.config.ts index 92f9e762..7e32c007 100644 --- a/app/static/vitest.config.ts +++ b/app/static/vitest.config.ts @@ -14,7 +14,7 @@ export default mergeConfig( coverage: { provider: "istanbul", include: ["src", "translationPackage"], - exclude: ["**/wodin.ts", "**/App.vue", "**/tests/**"] + exclude: ["**/wodin.ts", "**/App.vue", "**/tests/**", "**/wodinStatic.ts"] } } }) From 917330a5143f7492ceeb2ad3dbc7f10179589738 Mon Sep 17 00:00:00 2001 From: Mantra Date: Thu, 19 Dec 2024 01:10:43 +0000 Subject: [PATCH 02/17] code cov --- app/static/tests/unit/wodinStaticUtils.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/static/tests/unit/wodinStaticUtils.test.ts b/app/static/tests/unit/wodinStaticUtils.test.ts index 41986951..b059fd1b 100644 --- a/app/static/tests/unit/wodinStaticUtils.test.ts +++ b/app/static/tests/unit/wodinStaticUtils.test.ts @@ -32,6 +32,10 @@ describe("wodin static utils", () => { expect(s).toStrictEqual(stochasticStoreOptions); }); + test("get store options throws error if not correct app type", () => { + expect(() => getStoreOptions("hey" as any)).toThrowError("Unknown app type"); + }); + test("component and selector interpolates store correctly", () => { const { selector } = componentsAndSelectors("test-store")[0]; expect(selector).toContain(`data-w-store="test-store"`); From c786d14eac84d64f6723fc9f8eb0e00e2251dcfc Mon Sep 17 00:00:00 2001 From: Mantra Date: Thu, 19 Dec 2024 16:39:03 +0000 Subject: [PATCH 03/17] emmas comments --- app/static/src/externalScriptSrc.ts | 19 +++++++++- app/static/src/wodin.ts | 4 +- app/static/src/wodinStatic.ts | 26 ++++++++----- app/static/src/wodinStaticUtils.ts | 37 +++++++++++++++---- .../tests/unit/externalScriptSrc.test.ts | 4 +- .../tests/unit/wodinStaticUtils.test.ts | 19 +++++++--- 6 files changed, 82 insertions(+), 27 deletions(-) diff --git a/app/static/src/externalScriptSrc.ts b/app/static/src/externalScriptSrc.ts index e5f46723..5f440e74 100644 --- a/app/static/src/externalScriptSrc.ts +++ b/app/static/src/externalScriptSrc.ts @@ -1,8 +1,25 @@ +/* + Originally dynamic wodin returned a mustache template from the express server + which had a + + + + tag in it but with the introduction of the wodin static build, we no longer + control the html that is shipped to the user (the scientists write the html + research papers with the static build). + + We could enforce that they add this script tag manually otherwise mathjax + (latex style rendering in html) won't work but for the sake of the researchers' + experience lets make it easier and less prone to errors by just programmatically + adding the script tag. + + To load more external scripts, just append the src to the externalScripts array. +*/ export const externalScripts = [ "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" ]; -export const mountScriptTags = () => { +export const loadThirdPartyCDNScripts = () => { externalScripts.forEach(src => { const script = document.createElement("script"); script.defer = true; diff --git a/app/static/src/wodin.ts b/app/static/src/wodin.ts index 470251e2..5adb0666 100644 --- a/app/static/src/wodin.ts +++ b/app/static/src/wodin.ts @@ -12,11 +12,11 @@ import help from "./directives/help"; import App from "./components/App.vue"; import { getStoreOptions } from "./wodinStaticUtils"; import { Store, StoreOptions } from "vuex"; -import { mountScriptTags } from "./externalScriptSrc"; +import { loadThirdPartyCDNScripts } from "./externalScriptSrc"; declare let appType: AppType; -mountScriptTags(); +loadThirdPartyCDNScripts(); const { Basic, Fit, Stochastic } = AppType; export const getComponent = (appType: AppType) => { diff --git a/app/static/src/wodinStatic.ts b/app/static/src/wodinStatic.ts index dc8d9610..c495d6dc 100644 --- a/app/static/src/wodinStatic.ts +++ b/app/static/src/wodinStatic.ts @@ -3,17 +3,26 @@ import { getStoreOptions } from "./wodinStaticUtils"; import { Store, StoreOptions } from "vuex"; import "./scss/style.scss" import { AppState } from "./store/appState/state"; -import { mountScriptTags } from "./externalScriptSrc"; +import { loadThirdPartyCDNScripts } from "./externalScriptSrc"; import { - componentsAndSelectors, getConfigsAndModels, getDeepCopiedStoreOptions, + componentsAndSelectors, getConfigAndModelForStores, getDeepCopiedStoreOptions, getStoresInPage, initialiseStore, waitForBlockingScripts } from "./wodinStaticUtils"; +/* + This is the entrypoint to the wodin static build. This boot function just gets called + at the end of the file. Since we are not allowed top level awaits we have to create + an async function and call it at the bottom. + + To load any scripts that you need before the runs just append the src to the + blockingScripts array. +*/ + const blockingScripts = ["./stores/runnerOde.js", "./stores/runnerDiscrete.js"]; const boot = async () => { // inject external scripts such as mathjax - mountScriptTags(); + loadThirdPartyCDNScripts(); // wait for scripts we can't load without such as runner code await waitForBlockingScripts(blockingScripts); @@ -22,12 +31,11 @@ const boot = async () => { const storesInPage = getStoresInPage(); // get relevant store configs and model code - const { configs, modelResponses } = await getConfigsAndModels(storesInPage); + const configAndModelObj = await getConfigAndModelForStores(storesInPage); - storesInPage.forEach(async (s, i) => { - const { appType, defaultCode } = configs[i]; - const modelResponse = modelResponses[i]; - const originalStoreOptions = getStoreOptions(appType) as StoreOptions; + storesInPage.forEach(async s => { + const { config, modelResponse } = configAndModelObj[s]; + const originalStoreOptions = getStoreOptions(config.appType) as StoreOptions; // recursively deep copy state because without this we would have multiple // stores writing to the same state objects @@ -35,7 +43,7 @@ const boot = async () => { // manually initialise store because this is the responsibility of apps like // WodinSession which aren't used in the static build - await initialiseStore(store, appType, defaultCode, modelResponse); + await initialiseStore(store, config, modelResponse); // mount components to dom elements based on selectors componentsAndSelectors(s).forEach(({ component, selector }) => { diff --git a/app/static/src/wodinStaticUtils.ts b/app/static/src/wodinStaticUtils.ts index 52dd55ca..53aeeb5a 100644 --- a/app/static/src/wodinStaticUtils.ts +++ b/app/static/src/wodinStaticUtils.ts @@ -61,12 +61,23 @@ export const getStoresInPage = () => { return storesInPage; }; -export const getConfigsAndModels = async (storesInPage: string[]) => { +// TODO more tolerant error handling, maybe one config didnt work (for error handling PR) +export type StaticConfig = { appType: AppType, defaultCode: string[] }; +type ConfigAndModel = { config: StaticConfig, modelResponse: OdinModelResponse }; + +export const getConfigAndModelForStores = async (storesInPage: string[]) => { const configPromises = storesInPage.map(s => axios.get(`./stores/${s}/config.json`)); const modelResponsePromises = storesInPage.map(s => axios.get(`./stores/${s}/model.json`)); - const configs = (await Promise.all(configPromises)).map(res => res.data) as { appType: AppType, defaultCode: string[] }[]; + const configs = (await Promise.all(configPromises)).map(res => res.data) as StaticConfig[]; const modelResponses = (await Promise.all(modelResponsePromises)).map(res => res.data) as OdinModelResponse[]; - return { configs, modelResponses }; + + return Object.fromEntries(storesInPage.map((s, i) => { + const cfgAndModel: ConfigAndModel = { + config: configs[i], + modelResponse: modelResponses[i] + }; + return [ s, cfgAndModel ]; + })); }; export const getDeepCopiedStoreOptions = (storeOptions: StoreOptions) => { @@ -87,14 +98,26 @@ export const getDeepCopiedStoreOptions = (storeOptions: StoreOptions) declare let odinjs: OdinRunnerOde declare let dust: OdinRunnerDiscrete +/* + Traditionally in dynamic wodin, initialising the store is the responsibility + of the components on mount (such as WodinSession initialising the app config) + however, we do not mount these components anymore in static wodin. Static wodin + may or may not mount these components so we need to guarantee that these bits + of state are initialised. + + Note: we have not thoroughly explored mounting components that impact these + specific parts of the store yet as there is no need to in static wodin. In theory + we have disabled the API service so they should not change the state but if you + are getting API related errors on static build then it returning undefined as the + response may be the cause. +*/ export const initialiseStore = async ( - store: Store, appType: AppType, - defaultCode: string[], modelResponse: OdinModelResponse + store: Store, config: StaticConfig, modelResponse: OdinModelResponse ) => { const appConfigPayload = { - appType, + appType: config.appType, basicProp: "", - defaultCode, + defaultCode: config.defaultCode, endTime: 100, readOnlyCode: true, stateUploadIntervalMillis: 2_000_000, diff --git a/app/static/tests/unit/externalScriptSrc.test.ts b/app/static/tests/unit/externalScriptSrc.test.ts index 8d707594..e87ea544 100644 --- a/app/static/tests/unit/externalScriptSrc.test.ts +++ b/app/static/tests/unit/externalScriptSrc.test.ts @@ -1,8 +1,8 @@ -import { externalScripts, mountScriptTags } from "../../src/externalScriptSrc" +import { externalScripts, loadThirdPartyCDNScripts } from "../../src/externalScriptSrc" describe("external script src", () => { test("mounts enternal scripts to document", () => { - mountScriptTags(); + loadThirdPartyCDNScripts(); externalScripts.forEach((src, i) => { const script = document.body.getElementsByTagName("script").item(i) expect(script!.src).toBe(src); diff --git a/app/static/tests/unit/wodinStaticUtils.test.ts b/app/static/tests/unit/wodinStaticUtils.test.ts index b059fd1b..e55162d9 100644 --- a/app/static/tests/unit/wodinStaticUtils.test.ts +++ b/app/static/tests/unit/wodinStaticUtils.test.ts @@ -1,5 +1,9 @@ import { AppType } from "@/store/appState/state"; -import { componentsAndSelectors, getStoreOptions, waitForBlockingScripts, getStoresInPage, getConfigsAndModels, getDeepCopiedStoreOptions, initialiseStore } from "@/wodinStaticUtils"; +import { + componentsAndSelectors, getStoreOptions, waitForBlockingScripts, + getStoresInPage, getConfigAndModelForStores, getDeepCopiedStoreOptions, + initialiseStore +} from "@/wodinStaticUtils"; import { storeOptions as basicStoreOptions } from "@/store/basic/basic"; import { storeOptions as fitStoreOptions } from "@/store/fit/fit"; import { storeOptions as stochasticStoreOptions } from "@/store/stochastic/stochastic"; @@ -68,9 +72,11 @@ describe("wodin static utils", () => { test("can get configs and models for stores", async () => { const testStores = ["store1", "store2"]; - const { configs, modelResponses } = await getConfigsAndModels(testStores); - configs.forEach((c, i) => expect(c).toBe(mockConfigRes(testStores[i]))); - modelResponses.forEach((m, i) => expect(m).toBe(mockModelRes(testStores[i]))); + const configAndModelObj = await getConfigAndModelForStores(testStores); + testStores.forEach(s => { + expect(configAndModelObj[s].config).toBe(mockConfigRes(s)); + expect(configAndModelObj[s].modelResponse).toBe(mockModelRes(s)); + }) }); test("can deep copy store options", () => { @@ -97,8 +103,9 @@ describe("wodin static utils", () => { const commit = vi.fn(); const dispatch = vi.fn(); await initialiseStore( - { commit, dispatch } as any, AppType.Basic, - ["test", "code"], "test model res" as any + { commit, dispatch } as any, + { appType: AppType.Basic, defaultCode: ["test", "code"] }, + "test model res" as any ); expect(commit).toBeCalledTimes(4); From 3d04c47186a9060332683eaecf049de8ccb516e2 Mon Sep 17 00:00:00 2001 From: Mantra Date: Fri, 20 Dec 2024 18:43:20 +0000 Subject: [PATCH 04/17] add first draft of documentation --- README.md | 4 ++++ config-static/README.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 config-static/README.md diff --git a/README.md b/README.md index 42d9caf4..7053aa56 100644 --- a/README.md +++ b/README.md @@ -146,3 +146,7 @@ Use the `./scripts/run-version.sh` script setting the branch references for the This repo has two main parts: `app/server` (Express server) and `app/static` (Vue frontend). The normal way to spin up the app without hot reloading is build frontend, copy build files into public folder in the server (`npm run build --prefix=app/static` builds the frontend and copies the files to the server) and run the server (`npm run serve --prefix=app/server`). The html file will then look for its javascript src `wodin.js` and css src `wodin.css` in the `app/server/public` directory and load the app from these. The hot reloading setup is different. The `server.js` file (from building the server) takes in a `--hot-reload` boolean option which tells it to instead look for the javascript src at `http://localhost:5173/src/wodin.ts` since `wodin.ts` is our entrypoint (`http://localhost:5173` is the default url for `vite` dev mode and it serves the files, after transpiling to javascript, based on your folder structure) and css src at `http://localhost:5173/src/scss/style.scss`. To run it this way we run `npm run dev --prefix=app/static` to start the vite dev server and then we run `npm run serve-hot --prefix=app/server`. + +## Static build + +For details about the static build please read the README [here](./config-static/README.md) diff --git a/config-static/README.md b/config-static/README.md new file mode 100644 index 00000000..bcc60b9f --- /dev/null +++ b/config-static/README.md @@ -0,0 +1,35 @@ +# Wodin Static + +## Background + +We would like researchers to be able to build a site without a server and publish a static site somewhere like github pages. This will be to build interactive research papers/articles that allow policy makers to better understand a given model. Since the model will be static we can remove both the `express` backend, `redis` and `odin.api`. + +## Run/Development + +An example of the config a user needs to design is in this folder. To convert this example into a site run: +1. `./scripts/run-dev-dependencies.sh` +1. `./scripts/build-and-serve-static-site.sh` +The site should be available at `localhost:3000` (the run dependencies script is only involved in generating code for this site. Once the site is built, you can turn these off and still change the HTML document, e.g. adding a new graph, and it'll update everything accordingly when you refresh the page). + +## How it works + +There are two stages to this process, build time (code and static asset generation we do in a github action once the user has created a config) and run time (querying the HTML page and mounting the correct components with the correct stores). + +### Build time + +After the user writes the config, a github action will checkout this repo, run the `odin.api` docker container and run the `wodinBuilder.ts` script which takes in the path of the config and path of the output folder as args. This script expects a `stores` folder which contains folders that label your stores, ``. In each `` folder it expects a `config.json` and `model.R` file. This script does a couple of things: +1. Copies the `index.html` file the user has written to the output folder +1. Gets the Javascript code for the ODE and discrete runners and saves them into `stores/runnerOde.js` and `stores/runnerDiscrete.js` (the runner are generic and shared across all stores) +1. For each ``, it saves the config into `stores//config.json` and it compiles the model code and saves the response into `stores//model.json`. + +We also build `wodin` frontend in static mode (just normal `wodin` frontend with an early return in the api service so we don't do any network requests) and copy the js and css files into the output folder. After this, we just commit the output folder into a github pages branch and github will deploy it. + +### Run time + +The `wodinStatic.ts` script is the entrypoint for run time wodin static. This script does the following: +1. Loads third party CDN scripts (e.g. for mathjax). It does this by creating a script tag with the right source and appends it to the body of the HTML document. +1. Awaits blocking scripts, e.g. loading the `runnerOde.js` and `runnerDiscrete.js`, that the app cannot run without, these runners loaded as global variables `odinjs` and `dust` respectively. +1. Queries the document and finds all `data-w-store` tags (data tags that define what stores to use for that component). This is a list of all the stores the user has used in the HTML document and the values should match the `` folder names in the user's config. +1. Gets the `stores//config.json` and `stores//model.json` for only the stores the user has used in the page. +1. For each `` the user has used in the page, it initialises the store with the config, runners (using the global variables), model and finally runs the model with the runners. This initialisation was the responsibility of on mount hook of components like `WodinSession.vue` but we no longer mount those components so we have to manually initialise the store. Note: the api service is disabled in the wodin static build so returns undefined for all responses, this may break things if you want to mount certain components that do api requests. We intend to fix this soon. +1. For each `` we loop over the components we want to mount. We define a selector for each one in `componentsAndSelectors` function and query the DOM for these selectors making sure they have `data-w-store` tag with value ``. We mount the component to the correct selector with the correct store. From 31972ff34d575bad5b6885fd115ec35cfe3c0563 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:45:56 +0000 Subject: [PATCH 05/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-static/README.md b/config-static/README.md index bcc60b9f..80708fb1 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -4,6 +4,8 @@ We would like researchers to be able to build a site without a server and publish a static site somewhere like github pages. This will be to build interactive research papers/articles that allow policy makers to better understand a given model. Since the model will be static we can remove both the `express` backend, `redis` and `odin.api`. +Static wodin sites will allow the user to update parameters and re-run the model, fit data, run sensitivity and download data. They do not support updating model code or saving sessions. + ## Run/Development An example of the config a user needs to design is in this folder. To convert this example into a site run: From 4f1d36b352f5aa22f6fe0227d52688c924ca632d Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:46:33 +0000 Subject: [PATCH 06/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 80708fb1..bec9b8ec 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -11,7 +11,7 @@ Static wodin sites will allow the user to update parameters and re-run the model An example of the config a user needs to design is in this folder. To convert this example into a site run: 1. `./scripts/run-dev-dependencies.sh` 1. `./scripts/build-and-serve-static-site.sh` -The site should be available at `localhost:3000` (the run dependencies script is only involved in generating code for this site. Once the site is built, you can turn these off and still change the HTML document, e.g. adding a new graph, and it'll update everything accordingly when you refresh the page). +The site should be available at `localhost:3000` (the containers which are run by the dependencies script are only involved in generating the model javascript for this site. Once the site is built, you can stop the containers and still change the HTML document, e.g. adding a new graph, and it'll update everything accordingly when you refresh the page - you don't need to re-run the `build-and-serve` script either). ## How it works From 5ea22cd05190791262aa38e7cadadebb0b523746 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:51:24 +0000 Subject: [PATCH 07/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index bec9b8ec..e77f4506 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -34,4 +34,4 @@ The `wodinStatic.ts` script is the entrypoint for run time wodin static. This sc 1. Queries the document and finds all `data-w-store` tags (data tags that define what stores to use for that component). This is a list of all the stores the user has used in the HTML document and the values should match the `` folder names in the user's config. 1. Gets the `stores//config.json` and `stores//model.json` for only the stores the user has used in the page. 1. For each `` the user has used in the page, it initialises the store with the config, runners (using the global variables), model and finally runs the model with the runners. This initialisation was the responsibility of on mount hook of components like `WodinSession.vue` but we no longer mount those components so we have to manually initialise the store. Note: the api service is disabled in the wodin static build so returns undefined for all responses, this may break things if you want to mount certain components that do api requests. We intend to fix this soon. -1. For each `` we loop over the components we want to mount. We define a selector for each one in `componentsAndSelectors` function and query the DOM for these selectors making sure they have `data-w-store` tag with value ``. We mount the component to the correct selector with the correct store. +1. For each `` we loop over the components we want to mount. We define a selector for each one in `componentsAndSelectors` function and query the DOM for these selectors making sure they have `data-w-store` tag with value ``. We mount the component to the correct selector with the correct store. `index.html` in this folder includes examples of all supported components. From 2d6684cdac759a61ecbdca4e1950c2d83ce31c9c Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:52:07 +0000 Subject: [PATCH 08/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index e77f4506..658f775b 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -31,7 +31,7 @@ We also build `wodin` frontend in static mode (just normal `wodin` frontend with The `wodinStatic.ts` script is the entrypoint for run time wodin static. This script does the following: 1. Loads third party CDN scripts (e.g. for mathjax). It does this by creating a script tag with the right source and appends it to the body of the HTML document. 1. Awaits blocking scripts, e.g. loading the `runnerOde.js` and `runnerDiscrete.js`, that the app cannot run without, these runners loaded as global variables `odinjs` and `dust` respectively. -1. Queries the document and finds all `data-w-store` tags (data tags that define what stores to use for that component). This is a list of all the stores the user has used in the HTML document and the values should match the `` folder names in the user's config. +1. Queries the document and finds all tags with `data-w-store` attributes - these tags define the wodin components to render by class name, and the `data-w-store` attribute specifies the name of the store to use. These attribute values define all the stores requires by the site, and should match the `` folder names in the user's config. 1. Gets the `stores//config.json` and `stores//model.json` for only the stores the user has used in the page. 1. For each `` the user has used in the page, it initialises the store with the config, runners (using the global variables), model and finally runs the model with the runners. This initialisation was the responsibility of on mount hook of components like `WodinSession.vue` but we no longer mount those components so we have to manually initialise the store. Note: the api service is disabled in the wodin static build so returns undefined for all responses, this may break things if you want to mount certain components that do api requests. We intend to fix this soon. 1. For each `` we loop over the components we want to mount. We define a selector for each one in `componentsAndSelectors` function and query the DOM for these selectors making sure they have `data-w-store` tag with value ``. We mount the component to the correct selector with the correct store. `index.html` in this folder includes examples of all supported components. From bc64f10ea86787adb12ea35cce3133e1aaa6bf3a Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:52:16 +0000 Subject: [PATCH 09/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 658f775b..d24c3d63 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -6,7 +6,7 @@ We would like researchers to be able to build a site without a server and publis Static wodin sites will allow the user to update parameters and re-run the model, fit data, run sensitivity and download data. They do not support updating model code or saving sessions. -## Run/Development +## Example static site An example of the config a user needs to design is in this folder. To convert this example into a site run: 1. `./scripts/run-dev-dependencies.sh` From 07f5fd56ddd799846f55148bb691c4e8fb412ae1 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:57:30 +0000 Subject: [PATCH 10/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index d24c3d63..68de5183 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -21,7 +21,7 @@ There are two stages to this process, build time (code and static asset generati After the user writes the config, a github action will checkout this repo, run the `odin.api` docker container and run the `wodinBuilder.ts` script which takes in the path of the config and path of the output folder as args. This script expects a `stores` folder which contains folders that label your stores, ``. In each `` folder it expects a `config.json` and `model.R` file. This script does a couple of things: 1. Copies the `index.html` file the user has written to the output folder -1. Gets the Javascript code for the ODE and discrete runners and saves them into `stores/runnerOde.js` and `stores/runnerDiscrete.js` (the runner are generic and shared across all stores) +1. Gets the Javascript code for the ODE and discrete runners from `odin.api` and saves them into `stores/runnerOde.js` and `stores/runnerDiscrete.js` (the runner are generic and shared across all stores) 1. For each ``, it saves the config into `stores//config.json` and it compiles the model code and saves the response into `stores//model.json`. We also build `wodin` frontend in static mode (just normal `wodin` frontend with an early return in the api service so we don't do any network requests) and copy the js and css files into the output folder. After this, we just commit the output folder into a github pages branch and github will deploy it. From dbe6df320a3611ba1043cde1e122f2d9d0d05698 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 11:57:49 +0000 Subject: [PATCH 11/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 68de5183..4c449996 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -22,7 +22,7 @@ There are two stages to this process, build time (code and static asset generati After the user writes the config, a github action will checkout this repo, run the `odin.api` docker container and run the `wodinBuilder.ts` script which takes in the path of the config and path of the output folder as args. This script expects a `stores` folder which contains folders that label your stores, ``. In each `` folder it expects a `config.json` and `model.R` file. This script does a couple of things: 1. Copies the `index.html` file the user has written to the output folder 1. Gets the Javascript code for the ODE and discrete runners from `odin.api` and saves them into `stores/runnerOde.js` and `stores/runnerDiscrete.js` (the runner are generic and shared across all stores) -1. For each ``, it saves the config into `stores//config.json` and it compiles the model code and saves the response into `stores//model.json`. +1. For each ``, it saves the config into `stores//config.json` and it compiles the model code (also via the odin api) and saves the response into `stores//model.json`. We also build `wodin` frontend in static mode (just normal `wodin` frontend with an early return in the api service so we don't do any network requests) and copy the js and css files into the output folder. After this, we just commit the output folder into a github pages branch and github will deploy it. From 6a94dd59842d92f6658fadfb52dc4f38cb9bb2ec Mon Sep 17 00:00:00 2001 From: Mantra Date: Mon, 6 Jan 2025 12:07:45 +0000 Subject: [PATCH 12/17] emma changed: readme + test name --- app/server/tests/static-site-builder/builder.test.ts | 2 +- config-static/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/server/tests/static-site-builder/builder.test.ts b/app/server/tests/static-site-builder/builder.test.ts index d6571c5e..3968da89 100644 --- a/app/server/tests/static-site-builder/builder.test.ts +++ b/app/server/tests/static-site-builder/builder.test.ts @@ -62,7 +62,7 @@ describe("Wodin builder", () => { expectPath(tmpdir); }); - tmpdirTest("works as expected", async ({ tmpdir }) => { + tmpdirTest("generates site with correct files and folder structure", async ({ tmpdir }) => { const storesPath = p(tmpdir, "stores"); await buildSite(tmpdir); diff --git a/config-static/README.md b/config-static/README.md index 4c449996..b3204bc0 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -2,7 +2,7 @@ ## Background -We would like researchers to be able to build a site without a server and publish a static site somewhere like github pages. This will be to build interactive research papers/articles that allow policy makers to better understand a given model. Since the model will be static we can remove both the `express` backend, `redis` and `odin.api`. +We would like researchers to be able to build a site without a server and publish a static site somewhere like github pages. This will be to build interactive research papers/articles that allow policy makers to better understand the researchers' models. Since the models will be static we can remove both the `express` backend, `redis` and `odin.api`. Static wodin sites will allow the user to update parameters and re-run the model, fit data, run sensitivity and download data. They do not support updating model code or saving sessions. @@ -24,7 +24,7 @@ After the user writes the config, a github action will checkout this repo, run t 1. Gets the Javascript code for the ODE and discrete runners from `odin.api` and saves them into `stores/runnerOde.js` and `stores/runnerDiscrete.js` (the runner are generic and shared across all stores) 1. For each ``, it saves the config into `stores//config.json` and it compiles the model code (also via the odin api) and saves the response into `stores//model.json`. -We also build `wodin` frontend in static mode (just normal `wodin` frontend with an early return in the api service so we don't do any network requests) and copy the js and css files into the output folder. After this, we just commit the output folder into a github pages branch and github will deploy it. +We also build `wodin` frontend in static mode (just normal `wodin` frontend with an early return in the api service so we don't do any network requests since there are no `odin.api` and `redis` containers running) and copy the js and css files into the output folder. After this, we just commit the output folder into a github pages branch and github will deploy it. ### Run time From 10d5b1d9db9a7366f99ea68166419474281bc8e2 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Mon, 6 Jan 2025 12:08:38 +0000 Subject: [PATCH 13/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index b3204bc0..9e9a1caa 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -15,7 +15,7 @@ The site should be available at `localhost:3000` (the containers which are run b ## How it works -There are two stages to this process, build time (code and static asset generation we do in a github action once the user has created a config) and run time (querying the HTML page and mounting the correct components with the correct stores). +There are two stages to this process, build time (model code and static asset generation we do in a github action once the user has created a config) and run time (querying the HTML page and mounting the correct components with the correct stores). ### Build time From fd152be66a3c810cb1312b3f226587698ff3527c Mon Sep 17 00:00:00 2001 From: Mantra Date: Mon, 6 Jan 2025 12:17:58 +0000 Subject: [PATCH 14/17] update how it works section --- config-static/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config-static/README.md b/config-static/README.md index b3204bc0..92c76e12 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -13,9 +13,11 @@ An example of the config a user needs to design is in this folder. To convert th 1. `./scripts/build-and-serve-static-site.sh` The site should be available at `localhost:3000` (the containers which are run by the dependencies script are only involved in generating the model javascript for this site. Once the site is built, you can stop the containers and still change the HTML document, e.g. adding a new graph, and it'll update everything accordingly when you refresh the page - you don't need to re-run the `build-and-serve` script either). -## How it works +## How researchers will build the static site -There are two stages to this process, build time (code and static asset generation we do in a github action once the user has created a config) and run time (querying the HTML page and mounting the correct components with the correct stores). +Ther researchers will not be running the `build-and-serve-static-site.sh` manually. There are two stages to how the static site works for them: + +1. Build time: The researchers will be using a github action on a repository that looks like `config-static` to generate model code and static assets of the site. 1. Run time: The built Javascript querying the HTML page and mounting the correct components with the correct stores. ### Build time From e1e45ef0167eb0b0fbb73dce14a86e3a4450e6f3 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:25:03 +0000 Subject: [PATCH 15/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 92c76e12..9fde51b1 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -13,7 +13,7 @@ An example of the config a user needs to design is in this folder. To convert th 1. `./scripts/build-and-serve-static-site.sh` The site should be available at `localhost:3000` (the containers which are run by the dependencies script are only involved in generating the model javascript for this site. Once the site is built, you can stop the containers and still change the HTML document, e.g. adding a new graph, and it'll update everything accordingly when you refresh the page - you don't need to re-run the `build-and-serve` script either). -## How researchers will build the static site +## How researchers will build a static site Ther researchers will not be running the `build-and-serve-static-site.sh` manually. There are two stages to how the static site works for them: From f74d9b0c55580e28f3f3c574db5f2bd40b82a360 Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:25:28 +0000 Subject: [PATCH 16/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 9fde51b1..1febfb46 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -15,7 +15,7 @@ The site should be available at `localhost:3000` (the containers which are run b ## How researchers will build a static site -Ther researchers will not be running the `build-and-serve-static-site.sh` manually. There are two stages to how the static site works for them: +The researchers will not be running the `build-and-serve-static-site.sh` manually. There are two stages to how the static site works for them: 1. Build time: The researchers will be using a github action on a repository that looks like `config-static` to generate model code and static assets of the site. 1. Run time: The built Javascript querying the HTML page and mounting the correct components with the correct stores. From 2f742f2a0839dd5f8d13dba727004b55656670fc Mon Sep 17 00:00:00 2001 From: M-Kusumgar <98405247+M-Kusumgar@users.noreply.github.com> Date: Wed, 8 Jan 2025 14:25:46 +0000 Subject: [PATCH 17/17] Update config-static/README.md Co-authored-by: Emma Russell <44669576+EmmaLRussell@users.noreply.github.com> --- config-static/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-static/README.md b/config-static/README.md index 1febfb46..b0ba8266 100644 --- a/config-static/README.md +++ b/config-static/README.md @@ -17,7 +17,8 @@ The site should be available at `localhost:3000` (the containers which are run b The researchers will not be running the `build-and-serve-static-site.sh` manually. There are two stages to how the static site works for them: -1. Build time: The researchers will be using a github action on a repository that looks like `config-static` to generate model code and static assets of the site. 1. Run time: The built Javascript querying the HTML page and mounting the correct components with the correct stores. +1. Build time: The researchers will be using a github action on a repository that looks like `config-static` to generate model code and static assets of the site. +1. Run time: The built Javascript querying the HTML page and mounting the correct components with the correct stores. ### Build time