diff --git a/README.md b/README.md index 42ecf8e..dc57586 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,6 @@ If a module path is a non-local path including a protocol and a host then it is Now the module is imported as a black box - you cannot debug `BigNumber` methods. -Currently every external is loaded once and cached until Leporello is restarted -(TODO change path to modules every time it changed on disk, since modules are -served from service workers). - ## IO To enhance the interactive experience, Leporello.js traces the calls made to IO functions within your application. This trace can be replayed later, enabling you to program iteratively by making incremental changes to your code and promptly receiving feedback. @@ -197,6 +193,41 @@ allows to share localStorage between host Leporello.js instance and window where code is run) --> +## Saving state between page reloads + +Leporello.js allows preserving the state of the application between page reloads. To achieve this, Leporello.js provides a special API: + +```javascript +window.leporello.storage.get(key: string) +window.leporello.storage.set(key: string, value: any) +``` + +Unlike localStorage and sessionStorage, these functions allow saving and retrieving non-serializable objects. + +The storage can be cleared using the "(Re)open app window" button. + +You can try the online demo [here](https://app.leporello.tech/?example=todos-preact). Create TODO items, then edit the code, and you will observe that your TODOs are preserved. + +The code for interacting with the Leporello API is in the file `app.js`. When `app.js` module initializes, it checks whether Leporello.js API is present and loads app state: + +```javascript +let state + +if(globalThis.leporello) { + // Get initial state from Leporello storage + state = globalThis.leporello.storage.get('state') +} +``` + +Later, when state changes, it saves it back to the storage: + +```javascript +// on state change +if(globalThis.leporello) { + // Save state to Leporello storage to load it after page reload + globalThis.leporello.storage.set('state', state) +} +``` ## Run Leporello locally To run it locally, you need to clone repo to local folder and serve it via HTTPS protocol (HTTPS is required by [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API)). See [How to use HTTPS for local development](https://web.dev/how-to-use-local-https/) diff --git a/docs/examples/todos-preact/app.js b/docs/examples/todos-preact/app.js index e9e77dd..54542e8 100644 --- a/docs/examples/todos-preact/app.js +++ b/docs/examples/todos-preact/app.js @@ -2,8 +2,15 @@ import {render} from 'https://unpkg.com/preact?module'; let state, component, root +if(globalThis.leporello) { + // See https://github.com/leporello-js/leporello-js?tab=readme-ov-file#saving-state-between-page-reloads + // Get initial state from Leporello storage + state = globalThis.leporello.storage.get('state') +} + export const createApp = initial => { - /* if state is already initialized then preserve it */ + /* if state was loaded from Leporello storage then keep it, + * otherwise initialize with initial state */ state = state ?? initial.initialState component = initial.component root = initial.root @@ -12,6 +19,10 @@ export const createApp = initial => { export const handler = fn => (...args) => { state = fn(state, ...args) + if(globalThis.leporello) { + // Save state to Leporello storage to load it after page reload + globalThis.leporello.storage.set('state', state) + } do_render() } diff --git a/src/cmd.js b/src/cmd.js index bcdde7a..c683394 100644 --- a/src/cmd.js +++ b/src/cmd.js @@ -1,5 +1,5 @@ -import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq} - from './utils.js' +import {map_object, map_find, filter_object, collect_nodes_with_parents, uniq, + set_is_eq} from './utils.js' import { is_eq, is_child, ancestry, ancestry_inc, map_tree, find_leaf, find_fn_by_location, find_node, find_error_origin_node, @@ -69,14 +69,19 @@ const apply_eval_result = (state, eval_result) => { } } -const run_code = (s, dirty_files) => { +const run_code = (s, globals) => { + const is_globals_eq = s.globals == null + ? globals == null + : set_is_eq(s.globals, globals) const parse_result = load_modules(s.entrypoint, module => { - if(dirty_files != null && dirty_files.includes(module)) { + if(s.dirty_files != null && s.dirty_files.has(module)) { return s.files[module] } - if(s.parse_result != null) { + // If globals change, then errors for using undeclared identifiers may be + // no longer valid. Do not use cache + if(is_globals_eq) { const result = s.parse_result.cache[module] if(result != null) { return result @@ -87,10 +92,18 @@ const run_code = (s, dirty_files) => { return s.files[module] } - }, s.globals) + }, globals) + + const dirty_files = new Set( + [...(s.dirty_files ?? new Set())].filter(file => + parse_result.modules[file] == null + ) + ) const state = { ...s, + dirty_files, + globals, parse_result, calltree: null, modules: null, @@ -121,15 +134,7 @@ const run_code = (s, dirty_files) => { .map(i => i.node.full_import_path) ) - if( - external_imports.length != 0 - && - ( - state.external_imports_cache == null - || - external_imports.some(i => state.external_imports_cache[i] == null) - ) - ) { + if(external_imports.length != 0) { // Trigger loading of external modules return {...state, loading_external_imports_state: { @@ -138,18 +143,8 @@ const run_code = (s, dirty_files) => { } } else { // Modules were loaded and cached, proceed - return external_imports_loaded( - state, - state, - state.external_imports_cache == null - ? null - : filter_object( - state.external_imports_cache, - (module_name, module) => external_imports.includes(module_name) - ), - ) + return external_imports_loaded(state, state) } - } const external_imports_loaded = ( @@ -168,7 +163,6 @@ const external_imports_loaded = ( const state = { ...s, - external_imports_cache: external_imports, loading_external_imports_state: null } @@ -204,6 +198,7 @@ const external_imports_loaded = ( state.on_deferred_call, state.calltree_changed_token, state.io_trace, + state.storage, ) if(result.then != null) { @@ -252,10 +247,12 @@ const eval_modules_finished = (state, prev_state, result) => { const input = (state, code, index) => { const files = {...state.files, [state.current_module]: code} - const next = run_code( - set_cursor_position({...state, files}, index), - [state.current_module] - ) + const with_files = { + ...state, + files, + dirty_files: new Set([...(state.dirty_files ?? []), state.current_module]) + } + const next = set_cursor_position(with_files, index) const effect_save = { type: 'write', args: [ @@ -519,12 +516,10 @@ const change_current_module = (state, current_module) => { } const change_entrypoint = (state, entrypoint, current_module = entrypoint) => { - return run_code( - {...state, - entrypoint, - current_module, - } - ) + return {...state, + entrypoint, + current_module, + } } const change_html_file = (state, html_file) => { @@ -852,7 +847,7 @@ const on_deferred_call = (state, call, calltree_changed_token, logs) => { } const clear_io_trace = state => { - return run_code({...state, io_trace: null}) + return {...state, io_trace: null} } const load_files = (state, dir) => { @@ -892,7 +887,7 @@ const apply_entrypoint_settings = (state, entrypoint_settings) => { const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => { // Clear parse cache and rerun code const with_dir = load_files(state, dir) - return run_code({ + return { ...( entrypoint_settings == null ? with_dir @@ -904,30 +899,15 @@ const load_dir = (state, dir, has_file_system_access, entrypoint_settings) => { // remove cache. We have to clear cache because imports of modules that are // not available because project_dir is not available have errors and the // errors are cached - parse_result: null, - - external_imports_cache: null, - }) + parse_result: {...state.parse_result, cache: {}}, + } } const create_file = (state, dir, current_module) => { return {...load_dir(state, dir, true), current_module} } -const open_app_window = (state, globals) => { - // After we reopen run window, we should reload external modules in the - // context of new window. Clear external_imports_cache - return run_code({ - ...state, - globals, - external_imports_cache: null, - // Bust parse result cache because list of globals may change - parse_result: null, - // Clear io trace because promises in io_trace become invalid after their - // window close - io_trace: null, - }) -} +const open_app_window = state => ({...state, storage: new Map()}) const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => { const with_files = state.project_dir == null @@ -938,6 +918,7 @@ const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => { return { ...with_settings, + storage: new Map(), cursor_position_by_file: {[with_settings.current_module]: cursor_pos}, } } @@ -945,6 +926,7 @@ const get_initial_state = (state, entrypoint_settings, cursor_pos = 0) => { export const COMMANDS = { get_initial_state, input, + run_code, open_app_window, load_dir, create_file, diff --git a/src/editor/editor.js b/src/editor/editor.js index 383050c..14aaa12 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -1,4 +1,4 @@ -import {exec, get_state} from '../index.js' +import {exec, get_state, exec_and_reload_app_window} from '../index.js' import {ValueExplorer} from './value_explorer.js' import {stringify_for_header} from '../value_explorer_utils.js' import {el} from './domutils.js' @@ -98,7 +98,7 @@ export class Editor { normalize_events(this.ace_editor, { on_change: () => { try { - exec('input', this.ace_editor.getValue(), this.get_cursor_position()) + exec_and_reload_app_window('input', this.ace_editor.getValue(), this.get_cursor_position()) } catch(e) { // Do not throw Error to ACE because it breaks typing console.error(e) diff --git a/src/editor/files.js b/src/editor/files.js index 4038a23..1b8480b 100644 --- a/src/editor/files.js +++ b/src/editor/files.js @@ -6,7 +6,7 @@ import { exec, get_state, open_directory, - reload_app_window, + exec_and_reload_app_window, close_directory, } from '../index.js' @@ -20,16 +20,14 @@ export class Files { this.render(get_state()) } - change_entrypoint(e) { - const file = e.target.value - exec('change_entrypoint', file) + change_entrypoint(entrypoint, current_module) { + exec_and_reload_app_window('change_entrypoint', entrypoint, current_module) this.ui.editor.focus() } change_html_file(e) { const html_file = e.target.value - exec('change_html_file', html_file) - reload_app_window(get_state()) + exec_and_reload_app_window('change_html_file', html_file) } @@ -119,7 +117,7 @@ export class Files { name: 'js_entrypoint', value: file.path, checked: state.entrypoint == file.path, - change: e => this.change_entrypoint(e), + change: e => this.change_entrypoint(e.target.value), click: e => e.stopPropagation(), }) ) @@ -210,9 +208,9 @@ export class Files { // Reload all files for simplicity open_dir(false).then(dir => { if(is_dir) { - exec('load_dir', dir, true) + exec_and_reload_app_window('load_dir', dir, true) } else { - exec('create_file', dir, path) + exec_and_reload_app_window('create_file', dir, path) } }) } @@ -229,7 +227,7 @@ export class Files { if(file.path == null) { // root of examples dir, do nothing } else if(file.path == '') { - exec('change_entrypoint', '') + this.change_entrypoint('') } else { const find_node = n => n.path == file.path @@ -244,8 +242,7 @@ export class Files { // in examples mode, on click file we also change entrypoint for // simplicity const example = examples.find(e => e.path == example_dir.path) - exec( - 'change_entrypoint', + this.change_entrypoint( example.entrypoint, file.kind == 'directory' ? undefined : file.path ) diff --git a/src/editor/ui.js b/src/editor/ui.js index b1ae0cd..1371143 100644 --- a/src/editor/ui.js +++ b/src/editor/ui.js @@ -1,4 +1,4 @@ -import {exec, get_state, open_app_window} from '../index.js' +import {exec, get_state, open_app_window, exec_and_reload_app_window} from '../index.js' import {Editor} from './editor.js' import {Files} from './files.js' import {CallTree} from './calltree.js' @@ -73,7 +73,7 @@ export class UI { el('a', { 'class': 'statusbar_action first', href: 'javascript: void(0)', - click: () => exec('clear_io_trace') + click: () => this.clear_io_trace(), }, 'Clear IO trace (F6)' ), @@ -196,6 +196,10 @@ export class UI { } } + clear_io_trace() { + exec_and_reload_app_window('clear_io_trace') + } + open_app_window() { this.toggle_open_app_window_tooltip(false) localStorage.onboarding_open_app_window = true diff --git a/src/eval.js b/src/eval.js index 8f8f331..098177e 100644 --- a/src/eval.js +++ b/src/eval.js @@ -421,6 +421,7 @@ export const eval_modules = ( on_deferred_call, calltree_changed_token, io_trace, + storage, ) => { // TODO gensym __cxt, __trace, __trace_call, __calltree_node_by_loc, // __await_start, __await_finish, __Multiversion, __create_array, __create_object @@ -498,6 +499,8 @@ export const eval_modules = ( is_toplevel_call: true, window: globalThis.app_window, + + storage, } const result = run(module_fns, cxt, io_trace) diff --git a/src/find_definitions.js b/src/find_definitions.js index cb5b4a4..8223c80 100644 --- a/src/find_definitions.js +++ b/src/find_definitions.js @@ -80,6 +80,8 @@ const add_trivial_definition = node => { * will be assigned by the time the closures would be called */ +const DEFAULT_GLOBALS = new Set(['leporello']) // Leporello.js API + export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, module_name) => { // sanity check @@ -94,7 +96,7 @@ export const find_definitions = (ast, globals, scope = {}, closure_scope = {}, m } else { const definition = scope[ast.value] if(definition == null){ - if(globals.has(ast.value)) { + if(globals.has(ast.value) || DEFAULT_GLOBALS.has(ast.value)) { return {node: {...ast, definition: 'global'}, undeclared: null, closed: new Set()} } else { return {node: ast, undeclared: [ast], closed: new Set()} diff --git a/src/index.js b/src/index.js index 30a347d..3709bac 100644 --- a/src/index.js +++ b/src/index.js @@ -52,7 +52,7 @@ const get_html_url = state => { const on_window_load = w => { init_window_service_worker(w) exec( - 'open_app_window', + 'run_code', new Set(Object.getOwnPropertyNames(w)) ) } @@ -78,6 +78,7 @@ const open_run_iframe = (state) => { export const open_app_window = state => { // TODO set_error_handler? Or we dont need to set_error_handler for child // window because error is always caught by parent window handler? + exec('open_app_window') globalThis.app_window.close() globalThis.app_window = open(get_html_url(state)) init_app_window(globalThis.app_window) @@ -139,10 +140,8 @@ const init_app_window = w => { add_load_handler() } -export const reload_app_window = state => { - // TODO after window location reload, open_app_window command will be fired. - // Maybe we should have separate commands for open_app_window and - // reload_app_window? +const reload_app_window = state => { + // after window location reload, `run_code` command will be fired. globalThis.app_window.location = get_html_url(state) } @@ -154,19 +153,23 @@ const get_entrypoint_settings = () => { } } +export const exec_and_reload_app_window = (...exec_args) => { + exec(...exec_args) + reload_app_window(get_state()) +} export const open_directory = () => { if(globalThis.showDirectoryPicker == null) { throw new Error('Your browser is not supporting File System Access API') } open_dir(true).then(dir => { - exec('load_dir', dir, true, get_entrypoint_settings()) + exec_and_reload_app_window('load_dir', dir, true, get_entrypoint_settings()) }) } export const close_directory = async () => { close_dir() - exec('load_dir', await examples_dir_promise, false, get_entrypoint_settings()) + exec_and_reload_app_window('load_dir', await examples_dir_promise, false, get_entrypoint_settings()) } diff --git a/src/runtime/record_io.js b/src/runtime/record_io.js index 68115d2..696494f 100644 --- a/src/runtime/record_io.js +++ b/src/runtime/record_io.js @@ -76,18 +76,25 @@ const make_patched_method = (window, original, name, use_context) => { : original.apply(this, args) if(value?.[Symbol.toStringTag] == 'Promise') { - // TODO use __original_then, not finally which calls - // patched 'then'? - value = value.finally(() => { - if(cxt_copy != cxt) { - return - } - if(cxt.io_trace_is_replay_aborted) { - // Non necessary - return - } - cxt.io_trace.push({type: 'resolution', index}) - }) + value = value + .then(val => { + value.status = {ok: true, value: val} + return val + }) + .catch(error => { + value.status = {ok: true, error} + throw error + }) + .finally(() => { + if(cxt_copy != cxt) { + return + } + if(cxt.io_trace_is_replay_aborted) { + // Non necessary + return + } + cxt.io_trace.push({type: 'resolution', index}) + }) } ok = true @@ -150,10 +157,11 @@ const make_patched_method = (window, original, name, use_context) => { ) if(next_resolution != null && !cxt.io_trace_resolver_is_set) { - const original_setTimeout = cxt.window.setTimeout.__original cxt.io_trace_resolver_is_set = true - original_setTimeout(() => { + // use setTimeout function from host window (because this module was + // loaded as `external` by host window) + setTimeout(() => { if(cxt_copy != cxt) { return } @@ -180,14 +188,22 @@ const make_patched_method = (window, original, name, use_context) => { cxt.io_trace[cxt.io_trace_index].type == 'resolution' ) { const resolution = cxt.io_trace[cxt.io_trace_index] - const resolver = cxt.io_trace_resolvers.get(resolution.index) + const {resolve, reject} = cxt.io_trace_resolvers.get(resolution.index) cxt.io_trace_index++ if(cxt.io_trace[resolution.index].name == 'setTimeout') { - resolver() + resolve() } else { - resolver(cxt.io_trace[resolution.index].value) + const promise = cxt.io_trace[resolution.index].value + if(promise.status == null) { + throw new Error('illegal state') + } + if(promise.status.ok) { + resolve(promise.status.value) + } else { + reject(promise.status.error) + } } } } @@ -203,12 +219,12 @@ const make_patched_method = (window, original, name, use_context) => { // trace) and instanceof would not work if(call.value?.[Symbol.toStringTag] == 'Promise') { // Always make promise originate from app_window - return new cxt.window.Promise(resolve => { - cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, resolve) + return new cxt.window.Promise((resolve, reject) => { + cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve, reject}) }) } else if(name == 'setTimeout') { const timeout_cb = args[0] - cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, timeout_cb) + cxt.io_trace_resolvers.set(cxt.io_trace_index - 1, {resolve: timeout_cb}) return call.value } else { return call.value diff --git a/src/runtime/runtime.js b/src/runtime/runtime.js index 777c537..aeaa7ec 100644 --- a/src/runtime/runtime.js +++ b/src/runtime/runtime.js @@ -19,9 +19,8 @@ const gen_to_promise = gen_fn => { if(result.done){ return result.value } else { - // If promise - if(result.value?.then != null) { - return result.value.__original_then( + if(result.value?.[Symbol.toStringTag] == 'Promise') { + return result.value.then( value => next(gen.next(value)), error => next(gen.throw(error)), ) @@ -36,7 +35,7 @@ const gen_to_promise = gen_fn => { const make_promise_with_rejector = cxt => { let rejector - const p = new cxt.window.Promise(r => rejector = r) + const p = new Promise(r => rejector = r) return [p, rejector] } @@ -107,8 +106,8 @@ const do_run = function*(module_fns, cxt, io_trace){ create_array, create_object, ) - if(result instanceof cxt.window.Promise) { - yield cxt.window.Promise.race([replay_aborted_promise, result]) + if(result?.[Symbol.toStringTag] == 'Promise') { + yield Promise.race([replay_aborted_promise, result]) } else { yield result } @@ -144,7 +143,10 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) { if(!cxt.window.__is_initialized) { defineMultiversion(cxt.window) apply_io_patches(cxt.window) + inject_leporello_api(cxt) cxt.window.__is_initialized = true + } else { + throw new Error('illegal state') } const result = yield* do_run(module_fns, cxt, io_trace) @@ -154,14 +156,21 @@ export const run = gen_to_promise(function*(module_fns, cxt, io_trace) { result.rt_cxt.is_recording_deferred_calls = false // run again without io trace + // TODO reload app_window before second run return yield* do_run(module_fns, cxt, null) } else { return result } }) +const inject_leporello_api = cxt => { + cxt.window.leporello = { storage: cxt.storage } +} const apply_promise_patch = cxt => { + if(cxt.window.Promise.prototype.__original_then != null) { + throw new Error('illegal state') + } const original_then = cxt.window.Promise.prototype.then cxt.window.Promise.prototype.__original_then = cxt.window.Promise.prototype.then @@ -197,6 +206,7 @@ const apply_promise_patch = cxt => { const remove_promise_patch = cxt => { cxt.window.Promise.prototype.then = cxt.window.Promise.prototype.__original_then + delete cxt.window.Promise.prototype.__original_then } export const set_record_call = cxt => { @@ -339,7 +349,7 @@ const __trace = (cxt, fn, name, argscount, __location, get_closure, has_versione try { value = fn(...args) ok = true - if(value instanceof cxt.window.Promise) { + if(value?.[Symbol.toStringTag] == 'Promise') { set_record_call(cxt) } return value @@ -485,7 +495,7 @@ const __trace_call = (cxt, fn, context, args, errormessage, is_new = false) => { value = undefined } ok = true - if(value instanceof cxt.window.Promise) { + if(value?.[Symbol.toStringTag] == 'Promise') { set_record_call(cxt) } diff --git a/src/utils.js b/src/utils.js index 2ff47aa..4637606 100644 --- a/src/utils.js +++ b/src/utils.js @@ -10,6 +10,9 @@ export const set_push = (x,y) => new Set([...x, y]) export const set_union = (x,y) => new Set([...x, ...y]) +export const set_is_eq = (a, b) => + a.size === b.size && [...a].every(value => b.has(value)) + export const set_diff = (x,y) => { return new Set([...x].filter(el => !y.has(el))) } diff --git a/src/value_explorer_utils.js b/src/value_explorer_utils.js index b360a7f..7699c7c 100644 --- a/src/value_explorer_utils.js +++ b/src/value_explorer_utils.js @@ -11,8 +11,7 @@ const isError = object => || object instanceof globalThis.app_window.Error -const isPromise = object => - object instanceof globalThis.app_window.Promise +const isPromise = object => object?.[Symbol.toStringTag] == 'Promise' // Override behaviour for Date, becase Date has toJSON defined const isDate = object => diff --git a/test/run.js b/test/run.js index d4a9e7e..c6be90d 100644 --- a/test/run.js +++ b/test/run.js @@ -1,6 +1,6 @@ import {tests} from './test.js' // external -import {run} from './run_utils.js' +import {run} from './utils.js' await run(tests) diff --git a/test/run_utils.js b/test/run_utils.js index 12a92db..c84dd26 100644 --- a/test/run_utils.js +++ b/test/run_utils.js @@ -8,61 +8,47 @@ */ globalThis.Response -export const run = tests => { - // Runs test, return failure or null if not failed - const run_test = t => { - return Promise.resolve().then(t.test) - .then(() => null) - .catch(e => { - if(globalThis.process != null) { - // In node.js runner, fail fast - console.error('Failed: ' + t.message) - throw e - } else { - return e - } - }) - } +if(globalThis.process != null) { + globalThis.NodeVM = await import('node:vm') +} - // If not run in node, then dont apply filter - const filter = globalThis.process && globalThis.process.argv[2] +let iframe - if(filter == null) { +export function create_app_window() { + if(globalThis.process != null) { + // We are in node.js + // `NodeVM` was preloaded earlier - const only = tests.find(t => t.only) - const tests_to_run = only == null ? tests : [only] + const context = globalThis.NodeVM.createContext({ - // Exec each test. After all tests are done, we rethrow first error if - // any. So we will mark root calltree node if one of tests failed - return tests_to_run.reduce( - (failureP, t) => - Promise.resolve(failureP).then(failure => - run_test(t).then(next_failure => failure ?? next_failure) - ) - , - null - ).then(failure => { + process, - if(failure != null) { - throw failure - } else { - if(globalThis.process != null) { - console.log('Ok') - } - } + // for some reason URL is not available inside VM + URL, + console, + setTimeout, + // break fetch because we dont want it to be accidentally called in unit test + fetch: () => { + console.error('Error! fetch called') + }, }) + const get_global_object = globalThis.NodeVM.compileFunction( + 'return this', + [], // args + {parsingContext: context} + ) + + return get_global_object() } else { - const test = tests.find(t => t.message.includes(filter)) - if(test == null) { - throw new Error('test not found') - } else { - return run_test(test).then(() => { - if(globalThis.process != null) { - console.log('Ok') - } - }) + // We are in browser + if(iframe != null) { + globalThis.document.body.removeChild(iframe) } + iframe = globalThis.document.createElement('iframe') + document.body.appendChild(iframe) + return iframe.contentWindow } } + diff --git a/test/test.js b/test/test.js index 5f98e04..e125b1e 100644 --- a/test/test.js +++ b/test/test.js @@ -23,12 +23,12 @@ import { assert_code_error, assert_code_error_async, assert_versioned_value, assert_value_explorer, assert_selection, parse_modules, + run_code, test_initial_state, test_initial_state_async, test_deferred_calls_state, print_debug_ct_node, - command_input_async, - patch_builtin, - original_setTimeout, + input, + input_async, } from './utils.js' export const tests = [ @@ -1012,7 +1012,8 @@ export const tests = [ const spoil_file = {...s, files: {...s.files, 'c': ',,,'}} // change module '' - const {state: s2} = COMMANDS.input(spoil_file, 'import {c} from "c"', 0) + const {state: s2} = input(spoil_file, 'import {c} from "c"', 0) + assert_equal(s2.dirty_files, new Set()) assert_equal(s2.parse_result.ok, true) }), @@ -1110,7 +1111,7 @@ export const tests = [ const index = edited.indexOf('foo_var') - const {state, effects} = COMMANDS.input( + const {state, effects} = input( initial, edited, index @@ -1164,39 +1165,6 @@ export const tests = [ ) }), - test('module external cache', () => { - const code = ` - // external - import {foo_var} from 'foo.js' - console.log(foo_var) - ` - const initial = test_initial_state(code) - - const next = COMMANDS.external_imports_loaded(initial, initial, { - 'foo.js': { - ok: true, - module: { - 'foo_var': 'foo_value' - }, - } - }) - - const edited = ` - // external - import {foo_var} from 'foo.js' - foo_var - ` - - const {state} = COMMANDS.input( - next, - edited, - edited.lastIndexOf('foo_var'), - ) - - // If cache was not used then effects will be `load_external_imports` - assert_equal(state.value_explorer.result.value, 'foo_value') - }), - test('module external cache error bug', () => { const code = ` // external @@ -1221,7 +1189,7 @@ export const tests = [ ` // edit code - const {state} = COMMANDS.input( + const {state} = input( next, edited, edited.lastIndexOf('foo_var'), @@ -1259,7 +1227,7 @@ export const tests = [ const edited = `` // edit code - const {state, effects} = COMMANDS.input( + const {state, effects} = input( next, edited, 0, @@ -2000,7 +1968,7 @@ const y = x()` x(2); ` - const s3 = COMMANDS.input(s2, invalid, invalid.indexOf('return')).state + const s3 = input(s2, invalid, invalid.indexOf('return')).state const edited = ` const x = foo => { @@ -2010,7 +1978,7 @@ const y = x()` x(2); ` - const n = COMMANDS.input(s3, edited, edited.indexOf('return')).state + const n = input(s3, edited, edited.indexOf('return')).state const res = find_leaf(active_frame(n), edited.indexOf('*')) @@ -2044,7 +2012,7 @@ const y = x()` [1,2,3].map(x) ` - const e = COMMANDS.input(s4, edited, edited.indexOf('2')).state + const e = input(s4, edited, edited.indexOf('2')).state const active = active_frame(e) @@ -2070,7 +2038,7 @@ const y = x()` } ` - const {state: s2} = COMMANDS.input(s1, edited, edited.indexOf('1')) + const {state: s2} = input(s1, edited, edited.indexOf('1')) const s3 = COMMANDS.move_cursor(s2, edited.indexOf('import')) assert_equal(s3.value_explorer.result.value.x, 1) }), @@ -2098,7 +2066,7 @@ const y = x()` x() ` - const e = COMMANDS.input(s3, edited, edited.indexOf('123')).state + const e = input(s3, edited, edited.indexOf('123')).state assert_equal(e.active_calltree_node.toplevel, true) }), @@ -2111,7 +2079,7 @@ const y = x()` }), 'x' ) - const e = COMMANDS.input(s1, 'export const x = 2', 0).state + const e = input(s1, 'export const x = 2', 0).state assert_equal(e.current_calltree_node.module, '') assert_equal(e.active_calltree_node, null) }), @@ -2143,7 +2111,7 @@ const y = x()` ` const moved = COMMANDS.move_cursor(s3, code.indexOf('2')) - const e = COMMANDS.input(moved, edited, edited.indexOf('3')).state + const e = input(moved, edited, edited.indexOf('3')).state assert_equal(e.active_calltree_node, null) assert_equal(e.current_calltree_node.toplevel, true) }), @@ -2156,7 +2124,7 @@ const y = x()` x() ` const i = test_initial_state(code) - const edited = COMMANDS.input(i, code.replace('1', '100'), code.indexOf('1')).state + const edited = input(i, code.replace('1', '100'), code.indexOf('1')).state const left = COMMANDS.calltree.arrow_left(edited) assert_equal(left.active_calltree_node.toplevel, true) }), @@ -3550,12 +3518,12 @@ const y = x()` ` const {state: i, on_deferred_call} = test_deferred_calls_state(code) - const input = COMMANDS.input(i, code, 0).state + const state = input(i, code, 0).state // Make deferred call, calling fn from previous code i.modules[''].fn(1) - const result = on_deferred_call(input) + const result = on_deferred_call(state) // deferred calls must be null, because deferred calls from previous executions // must be discarded @@ -3911,7 +3879,7 @@ const y = x()` } await f() ` - const next = await command_input_async(i, code2, code2.indexOf('1')) + const next = await input_async(i, code2, code2.indexOf('1')) assert_equal(next.active_calltree_node.fn.name, 'f') assert_equal(next.value_explorer.result.value, 1) }), @@ -3955,22 +3923,21 @@ const y = x()` ` const {state: i, on_deferred_call} = test_deferred_calls_state(code) - await i.eval_modules_state.promise.__original_then(result => { - const s = COMMANDS.eval_modules_finished( - i, - i, - result, - i.eval_modules_state.node, - i.eval_modules_state.toplevel - ) + const result = await i.eval_modules_state.promise - // Make deferred call - s.modules[''].fn() - const state = on_deferred_call(s) - assert_equal(get_deferred_calls(state).length, 1) - assert_equal(get_deferred_calls(state)[0].value, 1) - }) + const s = COMMANDS.eval_modules_finished( + i, + i, + result, + i.eval_modules_state.node, + i.eval_modules_state.toplevel + ) + // Make deferred call + s.modules[''].fn() + const state = on_deferred_call(s) + assert_equal(get_deferred_calls(state).length, 1) + assert_equal(get_deferred_calls(state)[0].value, 1) }), test('async/await await argument bug', async () => { @@ -3991,101 +3958,94 @@ const y = x()` }), test('record io', () => { - // Patch Math.random to always return 1 - patch_builtin('random', () => 1) + let app_window_patches - const initial = test_initial_state(` - const x = Math.random() - `) + // Patch Math.random to always return 1 + app_window_patches = {'Math.random': () => 1} + + const initial = test_initial_state(`const x = Math.random()`, undefined, { + app_window_patches + }) // Now call to Math.random is cached, break it to ensure it was not called // on next run - patch_builtin('random', () => { throw 'fail' }) + app_window_patches = {'Math.random': () => { throw 'fail' }} - const next = COMMANDS.input(initial, `const x = Math.random()*2`, 0).state + const next = input(initial, `const x = Math.random()*2`, 0, {app_window_patches}).state assert_equal(next.value_explorer.result.value, 2) assert_equal(next.rt_cxt.io_trace_index, 1) // Patch Math.random to return 2. // TODO The first call to Math.random() is cached with value 1, and the // second shoud return 2 - patch_builtin('random', () => 2) - const replay_failed = COMMANDS.input( + app_window_patches = {'Math.random': () => 2} + const replay_failed = input( initial, `const x = Math.random() + Math.random()`, - 0 + 0, + {app_window_patches} ).state // TODO must reuse first cached call? assert_equal(replay_failed.value_explorer.result.value, 4) - - // Remove patch - patch_builtin('random', null) }), - test('record io trace discarded if args does not match', async () => { // Patch fetch - patch_builtin('fetch', async () => 'first') + let app_window_patches + app_window_patches = {fetch: async () => 'first'} const initial = await test_initial_state_async(` console.log(await fetch('url', {method: 'GET'})) - `) + `, undefined, {app_window_patches}) assert_equal(initial.logs.logs[0].args[0], 'first') // Patch fetch again - patch_builtin('fetch', async () => 'second') + app_window_patches = {fetch: async () => 'second'} - const cache_discarded = await command_input_async(initial, ` + const cache_discarded = await input_async(initial, ` console.log(await fetch('url', {method: 'POST'})) - `, 0) + `, 0, {app_window_patches}) assert_equal(cache_discarded.logs.logs[0].args[0], 'second') - - // Remove patch - patch_builtin('fetch', null) }), test('record io fetch rejects', async () => { // Patch fetch - patch_builtin('fetch', () => Promise.reject('fail')) + let app_window_patches + app_window_patches = {fetch: () => globalThis.app_window.Promise.reject('fail')} const initial = await test_initial_state_async(` await fetch('url', {method: 'GET'}) - `) + `, undefined, {app_window_patches}) assert_equal(root_calltree_node(initial).error, 'fail') // Patch fetch again - patch_builtin('fetch', () => async () => 'result') + app_window_patches = {fetch: () => async () => 'result'} - const with_cache = await command_input_async(initial, ` + const with_cache = await input_async(initial, ` await fetch('url', {method: 'GET'}) - `, 0) + `, 0, {app_window_patches}) assert_equal(root_calltree_node(initial).error, 'fail') - - // Remove patch - patch_builtin('fetch', null) }), test('record io preserve promise resolution order', async () => { // Generate fetch function which calls get resolved in reverse order - const {fetch, resolve} = new Function(` - const calls = [] - return { - fetch(...args) { - let resolver - const promise = new Promise(r => resolver = r) - calls.push({resolver, promise, args}) - return promise - }, + const calls = [] + function fetch(...args) { + let resolver + const promise = new (globalThis.app_window.Promise)(r => {resolver = r}) + calls.push({resolver, promise, args}) + return promise + } - resolve() { - [...calls].reverse().forEach(call => call.resolver(...call.args)) - }, - } - `)() + function resolve() { + [...calls].reverse().forEach(call => call.resolver(...call.args)) + } + + let app_window_patches // Patch fetch - patch_builtin('fetch', fetch) + app_window_patches = {fetch} const code = ` await Promise.all( @@ -4096,7 +4056,7 @@ const y = x()` ) ` - const initial_promise = test_initial_state_async(code) + const initial_promise = test_initial_state_async(code, undefined, {app_window_patches}) resolve() @@ -4106,56 +4066,54 @@ const y = x()` assert_equal(initial.logs.logs.map(l => l.args[0]), [3,2,1]) // Break fetch to ensure it is not get called anymore - patch_builtin('fetch', () => {throw 'broken'}) + app_window_patches = {fetch: () => {throw 'broken'}} - const with_cache = await command_input_async( + const with_cache = await input_async( initial, code, - 0 + 0, + {app_window_patches} ) // cached calls to fetch should be resolved in the same (reverse) order as // on the first run, so first call wins assert_equal(with_cache.logs.logs.map(l => l.args[0]), [3,2,1]) - - // Remove patch - patch_builtin('fetch', null) }), test('record io setTimeout', async () => { + let app_window_patches // Patch fetch to return result in 10ms - patch_builtin( - 'fetch', - () => new Promise(resolve => original_setTimeout(resolve, 10)) - ) + app_window_patches = { + fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 10)) + } const code = ` setTimeout(() => console.log('timeout'), 0) await fetch().then(() => console.log('fetch')) ` - const i = await test_initial_state_async(code) + const i = await test_initial_state_async(code, undefined, {app_window_patches}) // First executed setTimeout, then fetch assert_equal(i.logs.logs.map(l => l.args[0]), ['timeout', 'fetch']) // Break fetch to ensure it would not be called - patch_builtin('fetch', async () => {throw 'break'}) + app_window_patches = { + fetch: async () => {throw 'break'} + } - const with_cache = await command_input_async(i, code, 0) + const with_cache = await input_async(i, code, 0, {app_window_patches}) // Cache must preserve resolution order assert_equal(with_cache.logs.logs.map(l => l.args[0]), ['timeout', 'fetch']) - - patch_builtin('fetch', null) }), test('record io clear io trace', async () => { const s1 = test_initial_state(`Math.random()`) const rnd = s1.value_explorer.result.value - const s2 = COMMANDS.input(s1, `Math.random() + 1`, 0).state + const s2 = input(s1, `Math.random() + 1`, 0).state assert_equal(s2.value_explorer.result.value, rnd + 1) - const cleared = COMMANDS.clear_io_trace(s2) + const cleared = input(COMMANDS.clear_io_trace(s2), `Math.random() + 1`).state assert_equal( cleared.value_explorer.result.value == rnd + 1, false @@ -4185,8 +4143,8 @@ const y = x()` const rnd = i.active_calltree_node.children[0].value // Run two versions of code in parallel - const next = COMMANDS.input(i, `await Promise.resolve()`, 0) - const next2 = COMMANDS.input(i, `Math.random(1)`, 0).state + const next = input(i, `await Promise.resolve()`, 0) + const next2 = input(i, `Math.random(1)`, 0).state const next_rnd = i.active_calltree_node.children[0].value assert_equal(rnd, next_rnd) }), @@ -4211,10 +4169,10 @@ const y = x()` }), test('record io hangs bug', async () => { - patch_builtin( - 'fetch', - () => new Promise(resolve => original_setTimeout(resolve, 0)) - ) + let app_window_patches + app_window_patches = { + fetch: () => new (globalThis.app_window.Promise)(resolve => setTimeout(resolve, 0)) + } const code = ` const p = fetch('') @@ -4222,22 +4180,20 @@ const y = x()` await p ` - const i = await test_initial_state_async(code) + const i = await test_initial_state_async(code, undefined, {app_window_patches}) assert_equal(i.io_trace.length, 3) const next_code = `await fetch('')` - const state = await command_input_async(i, next_code, 0) + const state = await input_async(i, next_code, 0, {app_window_patches}) assert_equal(state.io_trace.length, 2) - - patch_builtin('fetch', null) }), test('record io logs recorded twice bug', () => { const code = `Math.random()` const i = test_initial_state(code) - const second = COMMANDS.input( + const second = input( i, `console.log(1); Math.random(); Math.random()`, 0 @@ -5401,4 +5357,19 @@ const y = x()` assert_value_explorer(i, expected) }), + + test('leporello storage API', () => { + const i = test_initial_state(` + const value = leporello.storage.get('value') + if(value == null) { + leporello.storage.set('value', 1) + } + `) + const with_storage = input(i, 'leporello.storage.get("value")', 0).state + assert_value_explorer(with_storage, 1) + const with_cleared_storage = run_code( + COMMANDS.open_app_window(with_storage) + ) + assert_value_explorer(with_cleared_storage, undefined) + }), ] diff --git a/test/utils.js b/test/utils.js index a55991c..9364645 100644 --- a/test/utils.js +++ b/test/utils.js @@ -3,60 +3,22 @@ import {parse, print_debug_node, load_modules} from '../src/parse_js.js' import {active_frame, pp_calltree, version_number_symbol} from '../src/calltree.js' import {COMMANDS} from '../src/cmd.js' +// external +import {create_app_window} from './run_utils.js' + // external import {with_version_number} from '../src/runtime/runtime.js' Object.assign(globalThis, { - // for convenince, to type just `log` instead of `console.log` + // for convenience, to type just `log` instead of `console.log` log: console.log, - } ) -export const patch_builtin = new Function(` -if(globalThis.process != null ) { - globalThis.app_window = globalThis -} else { - const iframe = globalThis.document.createElement('iframe') - globalThis.document.body.appendChild(iframe) - globalThis.app_window = iframe.contentWindow -} - let originals = globalThis.app_window.__builtins_originals - let patched = globalThis.app_window.__builtins_patched - if(originals == null) { - globalThis.app_window.__original_setTimeout = globalThis.setTimeout - // This code can execute twice when tests are run in self-hosted mode. - // Ensure that patches will be applied only once - originals = globalThis.app_window.__builtins_originals = {} - patched = globalThis.app_window.__builtins_patched = {} - - const patch = (obj, name) => { - originals[name] = obj[name] - obj[name] = (...args) => { - return patched[name] == null - ? originals[name].apply(null, args) - : patched[name].apply(null, args) - } - } - - // Substitute some builtin functions: fetch, setTimeout, Math.random to be - // able to patch them in tests - patch(globalThis.app_window, 'fetch') - patch(globalThis.app_window, 'setTimeout') - patch(globalThis.app_window.Math, 'random') - } - - return (name, fn) => { - patched[name] = fn - } -`)() - -export const original_setTimeout = globalThis.app_window.__original_setTimeout - export const do_parse = code => parse( code, - new Set(Object.getOwnPropertyNames(globalThis.app_window)) + new Set(Object.getOwnPropertyNames(globalThis)) ) export const parse_modules = (entry, modules) => @@ -102,18 +64,48 @@ export const assert_code_error_async = async (codestring, error) => { assert_equal(result.error, error) } +function patch_app_window(app_window_patches) { + Object.entries(app_window_patches).forEach(([path, value]) => { + let obj = globalThis.app_window + const path_arr = path.split('.') + path_arr.forEach((el, i) => { + if(i == path_arr.length - 1) { + return + } + obj = obj[el] + }) + const prop = path_arr.at(-1) + obj[prop] = value + }) +} + +export const run_code = (state, app_window_patches) => { + globalThis.app_window = create_app_window() + if(app_window_patches != null) { + patch_app_window(app_window_patches) + } + return COMMANDS.run_code( + state, + state.globals ?? new Set(Object.getOwnPropertyNames(globalThis.app_window)) + ) +} + export const test_initial_state = (code, cursor_pos, options = {}) => { if(cursor_pos < 0) { throw new Error('illegal cursor_pos') } + if(typeof(options) != 'object') { + throw new Error('illegal state') + } const { - //entrypoint = '', current_module, project_dir, on_deferred_call, + app_window_patches, } = options const entrypoint = options.entrypoint ?? '' - return COMMANDS.open_app_window( + + return run_code( COMMANDS.get_initial_state( { files: typeof(code) == 'object' ? code : { '' : code}, @@ -126,7 +118,7 @@ export const test_initial_state = (code, cursor_pos, options = {}) => { }, cursor_pos ), - new Set(Object.getOwnPropertyNames(globalThis.app_window)) + app_window_patches, ) } @@ -141,8 +133,19 @@ export const test_initial_state_async = async (code, ...args) => { ) } -export const command_input_async = async (...args) => { - const after_input = COMMANDS.input(...args).state +export const input = (s, code, index, options = {}) => { + if(typeof(options) != 'object') { + throw new Error('illegal state') + } + const {state, effects} = COMMANDS.input(s, code, index) + return { + state: run_code(state, options.app_window_patches), + effects, + } +} + +export const input_async = async (...args) => { + const after_input = input(...args).state const result = await after_input.eval_modules_state.promise return COMMANDS.eval_modules_finished( after_input, @@ -239,3 +242,69 @@ export const test = (message, test, only = false) => { } export const test_only = (message, t) => test(message, t, true) + +// Create `run` function like this because we do not want its internals to be +// present in calltree +export const run = new Function('create_app_window', ` +return function run(tests) { + // create app window for simple tests, that do not use 'test_initial_state' + globalThis.app_window = create_app_window() + + // Runs test, return failure or null if not failed + const run_test = t => { + return Promise.resolve().then(t.test) + .then(() => null) + .catch(e => { + if(globalThis.process != null) { + // In node.js runner, fail fast + console.error('Failed: ' + t.message) + throw e + } else { + return e + } + }) + } + + // If not run in node, then dont apply filter + const filter = globalThis.process && globalThis.process.argv[2] + + if(filter == null) { + + const only = tests.find(t => t.only) + const tests_to_run = only == null ? tests : [only] + + // Exec each test. After all tests are done, we rethrow first error if + // any. So we will mark root calltree node if one of tests failed + return tests_to_run.reduce( + (failureP, t) => + Promise.resolve(failureP).then(failure => + run_test(t).then(next_failure => failure ?? next_failure) + ) + , + null + ).then(failure => { + + if(failure != null) { + throw failure + } else { + if(globalThis.process != null) { + console.log('Ok') + } + } + + }) + + } else { + const test = tests.find(t => t.message.includes(filter)) + if(test == null) { + throw new Error('test not found') + } else { + return run_test(test).then(() => { + if(globalThis.process != null) { + console.log('Ok') + } + }) + } + } +} +`)(create_app_window)