diff --git a/UI/git_sidebar/gitui.js b/UI/git_sidebar/gitui.js
index 1fb38aa..cfc9502 100644
--- a/UI/git_sidebar/gitui.js
+++ b/UI/git_sidebar/gitui.js
@@ -8,12 +8,15 @@ class GitUi {
this.workspace = null;
this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' };
this.listening = false;
+ this.refreshToken = 0;
+ this.commitMessage = '';
}
async setWorkspace(workspace) {
this.workspace = workspace || null;
this.gitman.setWorkspace(this.workspace);
if (!this.workspace) {
+ ++this.refreshToken;
this.state = { mode: 'empty', entries: [], commits: [], message: 'Open a folder to use Git.', messageType: 'info' };
this.render();
return;
@@ -26,7 +29,10 @@ class GitUi {
this.listening = true;
this.containerEl.addEventListener('click', (event) => this.handleClick(event));
this.containerEl.addEventListener('input', (event) => {
- if (event.target.id === 'git-commit-message') this.renderActionState();
+ if (event.target.id === 'git-commit-message') {
+ this.commitMessage = event.target.value;
+ this.renderActionState();
+ }
});
}
@@ -46,6 +52,7 @@ class GitUi {
this.setMessage(`${this.actionLabel(method)}…`, 'loading');
try {
const output = await this.gitman[method](argument);
+ if (method === 'commit') this.commitMessage = '';
await this.refresh(`${this.actionLabel(method)} completed${output ? `: ${output}` : '.'}`);
} catch (error) {
this.setMessage(error.message || String(error), 'error');
@@ -58,6 +65,7 @@ class GitUi {
async refresh(successMessage = '') {
if (!this.workspace) return;
+ const refreshToken = ++this.refreshToken;
this.state = { ...this.state, mode: 'loading', message: 'Loading repository status…', messageType: 'loading' };
this.render();
try {
@@ -65,8 +73,10 @@ class GitUi {
this.gitman.status(),
typeof this.gitman.log === 'function' ? this.gitman.log().then((raw) => ({ raw }), (error) => ({ error: error?.message || String(error) })) : Promise.resolve({ raw: '' })
]);
+ if (refreshToken !== this.refreshToken) return;
this.state = { mode: 'repository', entries: StatusStub.parse(raw), commits: LogStub.parse(log.raw), historyError: log.error || '', message: successMessage || 'Repository status is up to date.', messageType: successMessage ? 'success' : 'info' };
} catch (error) {
+ if (refreshToken !== this.refreshToken) return;
if (/not a git repository/i.test(error.message || '')) {
this.state = { mode: 'non-repository', entries: [], commits: [], message: 'This folder is not a Git repository.', messageType: 'info' };
} else {
@@ -147,7 +157,7 @@ class GitUi {
history.append(item);
}
panel.append(history);
- const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', disabled: busy || !this.state.entries.length });
+ const input = this.create('input', { id: 'git-commit-message', className: 'git-commit-input', type: 'text', placeholder: 'Commit message', value: this.commitMessage, disabled: busy || !this.state.entries.length });
input.setAttribute('aria-label', 'Commit message');
panel.append(input, this.button('Commit', 'commit', true));
const list = this.create('ul', { className: 'git-status-list' });
diff --git a/UI/git_sidebar/gitui.test.js b/UI/git_sidebar/gitui.test.js
index f173d61..a62e29b 100644
--- a/UI/git_sidebar/gitui.test.js
+++ b/UI/git_sidebar/gitui.test.js
@@ -7,7 +7,7 @@ class FakeElement {
append(...children) { this.children.push(...children); }
replaceChildren(...children) { this.children = children; }
setAttribute(name, value) { this[name] = value; }
- addEventListener() {}
+ addEventListener(type, listener) { this.listeners ||= {}; this.listeners[type] = listener; }
querySelector(selector) {
const matches = (element) => selector.startsWith('#') ? element.id === selector.slice(1) : selector.startsWith('.') ? element.className?.split(' ').includes(selector.slice(1)) : selector === '[data-git-action="commit"]' && element.dataset.gitAction === 'commit';
const visit = (element) => matches(element) ? element : element.children.map(visit).find(Boolean);
@@ -127,3 +127,58 @@ test('keeps status available on history failure and clears the error after recov
assert.match(container.textContent, /No commits yet/);
assert.doesNotMatch(container.textContent, /Unable to load commit history/);
});
+
+
+test('only the newest refresh can apply success or failure, even after changing or clearing workspaces', async () => {
+ for (const staleFails of [false, true]) {
+ for (const nextWorkspace of ['/other', null]) {
+ let finish;
+ const pending = new Promise((resolve, reject) => { finish = () => staleFails ? reject(new Error('stale failure')) : resolve(' M stale.js'); });
+ const gitman = { setWorkspace() {}, status: () => pending };
+ const ui = new GitUi(createContainer(), { gitman });
+ const oldRefresh = ui.setWorkspace('/workspace');
+ gitman.status = async () => ' M current.js';
+ await ui.setWorkspace(nextWorkspace);
+ const currentState = ui.state;
+ finish();
+ await oldRefresh;
+ assert.equal(ui.state, currentState);
+ assert.equal(ui.state.mode, nextWorkspace ? 'repository' : 'empty');
+ }
+ }
+ const pending = deferred();
+ const gitman = { setWorkspace() {}, status: () => pending.promise };
+ const ui = new GitUi(createContainer(), { gitman });
+ const oldRefresh = ui.setWorkspace('/workspace');
+ gitman.status = async () => ' M newest.js';
+ await ui.refresh();
+ pending.resolve(' M stale.js');
+ await oldRefresh;
+ assert.match(ui.containerEl.textContent, /newest.js/);
+ assert.doesNotMatch(ui.containerEl.textContent, /stale.js/);
+});
+
+test('preserves commit input through loading and failure and clears it after a successful commit', async () => {
+ const container = createContainer();
+ const pending = deferred();
+ const gitman = { setWorkspace() {}, status: async () => ' M file.js', commit: () => pending.promise };
+ const ui = new GitUi(container, { gitman });
+ ui.initListeners();
+ await ui.setWorkspace('/workspace');
+ const input = container.querySelector('#git-commit-message');
+ input.value = 'Keep this draft';
+ container.listeners.input({ target: input });
+ const action = ui.runAction('commit', input.value);
+ assert.equal(container.querySelector('#git-commit-message').value, 'Keep this draft');
+ pending.resolve('');
+ await action;
+ assert.equal(ui.commitMessage, '');
+ assert.equal(container.querySelector('#git-commit-message').value, '');
+ input.value = 'Retry this draft';
+ container.listeners.input({ target: input });
+ gitman.commit = async () => { throw new Error('commit rejected'); };
+ await ui.runAction('commit', input.value);
+ await ui.refresh();
+ assert.equal(ui.commitMessage, 'Retry this draft');
+ assert.equal(container.querySelector('#git-commit-message').value, 'Retry this draft');
+});
diff --git a/docs/index.html b/docs/index.html
index e1fd9d6..736fe5e 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -14,6 +14,7 @@
SATISCODE
+ Web IDE
About
Versions
Contributing
diff --git a/docs/pages/about.html b/docs/pages/about.html
index 70d722e..b207e2e 100644
--- a/docs/pages/about.html
+++ b/docs/pages/about.html
@@ -15,6 +15,7 @@ SATISCODE
🌙 Dark Mode
Home
+ Web IDE
Versions
Contributing
Sponsors
diff --git a/docs/pages/contribute.html b/docs/pages/contribute.html
index 10c0408..3e1fb3f 100644
--- a/docs/pages/contribute.html
+++ b/docs/pages/contribute.html
@@ -15,6 +15,7 @@ SATISCODE
🌙 Dark Mode
Home
+ Web IDE
Versions
About
Sponsors
diff --git a/docs/pages/sponsors.html b/docs/pages/sponsors.html
index e1a94d0..20630b2 100644
--- a/docs/pages/sponsors.html
+++ b/docs/pages/sponsors.html
@@ -15,6 +15,7 @@ SATISCODE
🌙 Dark Mode
Home
+ Web IDE
Versions
About
Contributing
diff --git a/docs/pages/versions.html b/docs/pages/versions.html
index 98cc5f3..f694882 100644
--- a/docs/pages/versions.html
+++ b/docs/pages/versions.html
@@ -15,6 +15,7 @@ SATISCODE
🌙 Dark Mode
Home
+ Web IDE
About
Contributing
Sponsors
diff --git a/docs/webIDE.behavior.test.js b/docs/webIDE.behavior.test.js
new file mode 100644
index 0000000..2c41b25
--- /dev/null
+++ b/docs/webIDE.behavior.test.js
@@ -0,0 +1,250 @@
+const assert = require('node:assert/strict');
+const { readFileSync } = require('node:fs');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+const vm = require('node:vm');
+const { test } = require('node:test');
+
+const webDir = path.join(__dirname, 'webIDE');
+const read = (file) => readFileSync(file, 'utf8');
+
+class Element {
+ constructor() { this.listeners = {}; this.children = []; this.style = { setProperty() {} }; this.dataset = {}; this.classList = { add() {}, remove() {} }; }
+ addEventListener(type, listener) { this.listeners[type] = listener; }
+ setAttribute() {}
+ querySelector() { return null; }
+ append(...children) { this.children.push(...children); }
+ appendChild(child) { this.append(child); }
+ removeChild(child) { this.children = this.children.filter((item) => item !== child); }
+ replaceChildren(...children) { this.children = children; }
+ focus() {}
+ showModal() {}
+ click() { return this.onclick?.(); }
+}
+
+function createDocument() {
+ const elements = new Map();
+ const options = ['json', 'python', 'auto'].map((language) => Object.assign(new Element(), { dataset: { sourceLanguage: language } }));
+ const document = {
+ body: new Element(), documentElement: new Element(),
+ createElement: () => new Element(),
+ getElementById(id) {
+ if (!elements.has(id)) elements.set(id, new Element());
+ return elements.get(id);
+ },
+ querySelector: () => new Element(),
+ querySelectorAll: (selector) => selector === '[data-source-language]' ? options : []
+ };
+ return { document, options };
+}
+
+function createApi() {
+ const { document } = createDocument();
+ const downloads = [];
+ const window = {};
+ const errors = [];
+ const context = vm.createContext({ window, document, localStorage: { getItem: () => null }, alert() {}, console: { ...console, error: (...args) => errors.push(args) },
+ URL: { createObjectURL: () => 'blob:test', revokeObjectURL() {} } });
+ vm.runInContext(read(path.join(webDir, 'web-api.js')), context);
+ class JSZip {
+ constructor() { this.files = new Map(); downloads.push(this.files); }
+ file(name, content) { this.files.set(name, content); }
+ async generateAsync() { return {}; }
+ }
+ return { window, document, downloads, JSZip, errors };
+}
+
+test('ZIP saves report failures and persist existing and new files only as real file paths', async () => {
+ const { window, downloads, JSZip, errors } = createApi();
+ const api = window.api;
+ assert.equal(await api.saveFile(), null);
+ assert.equal(await api.saveFile({ filePath: '/workspace/missing.txt' }), null);
+ assert.equal(await api.saveProjectZip(), false);
+ assert.equal(await api.saveFile({ filePath: '/workspace/new.txt', content: 'keep unsaved text' }), null);
+ window.JSZip = JSZip;
+ assert.equal(await api.saveFile({ filePath: '/workspace/new.txt', content: 'saved' }), '/workspace/new.txt');
+ assert.equal(downloads.at(-1).get('workspace/new.txt'), 'saved');
+ const first = await api.saveFile({ content: '' });
+ const second = await api.saveFile({ content: 'second' });
+ assert.match(first, /^\/workspace\/[^/]+\.txt$/);
+ assert.notEqual(first, second);
+ assert.equal((await api.readFile(second)).content, 'second');
+ assert.equal(downloads.at(-1).get(second.slice(1)), 'second');
+ assert.equal(await api.saveProjectZip(), true);
+ window.JSZip = class extends JSZip { async generateAsync() { throw new Error('export failed'); } };
+ assert.equal(await api.saveFile({ filePath: first, content: 'retry me' }), null);
+ assert.equal((await api.readFile(first)).content, 'retry me');
+ assert.equal(errors.length, 1);
+});
+
+test('directory fallback retains nested paths and duplicate basenames in exports and listings', async () => {
+ const { window, document, downloads, JSZip } = createApi();
+ window.JSZip = JSZip;
+ document.createElement = (tag) => {
+ if (tag !== 'input') return new Element();
+ const input = new Element();
+ input.files = [
+ { name: 'same.txt', webkitRelativePath: 'project/left/same.txt', text: async () => 'left' },
+ { name: 'same.txt', webkitRelativePath: 'project/right/same.txt', text: async () => 'right' },
+ { name: 'fallback.txt', text: async () => 'fallback' }
+ ];
+ input.onclick = () => input.onchange();
+ return input;
+ };
+ assert.equal(await window.api.openFolder(), '/project');
+ const left = await window.api.listDirectory('/project/left');
+ assert.equal(left[0].name, 'same.txt');
+ assert.equal(left[0].path, '/project/left/same.txt');
+ await window.api.saveProjectZip();
+ assert.equal(downloads.at(-1).get('project/left/same.txt'), 'left');
+ assert.equal(downloads.at(-1).get('project/right/same.txt'), 'right');
+ assert.equal(downloads.at(-1).get('project/fallback.txt'), 'fallback');
+});
+
+test('Markdown fails closed when either dependency is missing and sanitizes parsed output', () => {
+ const source = read(path.join(webDir, 'app.js'));
+ const view = source.slice(source.indexOf('class WebMarkdownView'), source.indexOf('const translations'));
+ const markdown = '
';
+ for (const window of [{}, { marked: { parse() { throw new Error('must not parse'); } } }, { DOMPurify: { sanitize() { throw new Error('must not sanitize'); } } }]) {
+ const article = { set innerHTML(_) { throw new Error('unsafe HTML assignment'); } };
+ vm.runInNewContext(`${view}\nnew WebMarkdownView(container).render(markdown);`, { window, markdown, container: { querySelector: () => article } });
+ assert.equal(article.textContent, markdown);
+ }
+ const article = {};
+ vm.runInNewContext(`${view}\nnew WebMarkdownView(container).render(markdown);`, {
+ window: { marked: { parse: (text) => { assert.equal(text, markdown); return 'parsed'; } }, DOMPurify: { sanitize: (html) => { assert.equal(html, 'parsed'); return 'clean'; } } },
+ markdown, container: { querySelector: () => article }
+ });
+ assert.equal(article.innerHTML, 'clean');
+});
+
+function loadModule(file, globals, exports) {
+ const source = read(file).replace(/\bexport /g, '').replaceAll('import.meta.url', JSON.stringify(pathToFileURL(file).href));
+ return vm.runInNewContext(`${source}\n({ ${exports.join(', ')} });`, { URL, console, ...globals });
+}
+
+test('root and shared loaders use module-relative URLs, reject HTTP failures, and resolve icon aliases', async () => {
+ for (const dir of [path.join(__dirname, '..'), path.join(webDir, 'shared')]) {
+ const iconsFile = path.join(dir, 'icons.js');
+ const map = { ui: { newFile: 'fallback' }, extensions: { '.custom': 'custom' }, languages: { javascript: 'js', python: 'py', plaintext: 'txt', c: 'c' } };
+ let ok = true;
+ const icons = loadModule(iconsFile, { fetch: async (url) => {
+ assert.equal(url.href, new URL('./icons/style1/icons.json', pathToFileURL(iconsFile)).href);
+ return { ok, status: 404, json: async () => { assert.equal(ok, true); return map; } };
+ }, console: { error() {} } }, ['initIcons', 'getFileIcon']);
+ await icons.initIcons();
+ for (const [file, expected] of [[null, 'fallback'], ['', 'fallback'], ['c', 'fallback'], ['README', 'fallback'], ['file.JS', 'js'], ['a.py', 'py'], ['a.txt', 'txt'], ['a.custom', 'custom'], ['a.c', 'c'], ['a.unknown', 'fallback']]) {
+ assert.equal(icons.getFileIcon(file), expected);
+ }
+ ok = false;
+ await icons.initIcons();
+ assert.equal(icons.getFileIcon('a.js'), 'js');
+ const themesFile = path.join(dir, 'themes.js');
+ let calls = 0;
+ const theme = { colors: {} };
+ const themes = loadModule(themesFile, { fetch: async (url) => {
+ calls++;
+ assert.equal(url.href, new URL('./themes/dark.json', pathToFileURL(themesFile)).href);
+ return { ok, status: 503, json: async () => { assert.equal(ok, true); return theme; } };
+ } }, ['loadTheme']);
+ await assert.rejects(themes.loadTheme('dark'), /dark.*503/);
+ ok = true;
+ assert.equal(await themes.loadTheme('dark'), theme);
+ assert.equal(await themes.loadTheme('dark'), theme);
+ assert.equal(calls, 2);
+ }
+});
+
+async function createApp() {
+ const { window } = createApi();
+ const { document: appDocument, options } = createDocument();
+ const messages = [];
+ let starts = 0;
+ let ready;
+ let tabs;
+ let onChange;
+ let complete;
+ let start;
+ const started = new Promise((resolve) => { start = resolve; });
+ window.api.startFixson = () => { starts++; return started; };
+ const send = window.api.sendFixsonMessage;
+ window.api.sendFixsonMessage = (msg) => { messages.push(msg); send(msg); };
+ const model = (text, language) => ({ getValue: () => text, setValue(value) { text = value; }, language });
+ const editor = { setModel(value) { this.model = value; }, getModel() { return this.model; }, getValue() { return this.model.getValue(); }, onDidChangeModelContent(cb) { onChange = cb; } };
+ const monaco = { Uri: { file: (file) => ({ toString: () => `file://${file}` }) }, MarkerSeverity: { Error: 8 }, editor: { create: () => editor, createModel: model, setTheme() {}, setModelLanguage: (model, language) => { model.language = language; }, setModelMarkers: (model, owner, markers) => { model.markers = markers; } }, languages: { CompletionItemKind: { Property: 1 }, registerCompletionItemProvider: (_, provider) => { complete = provider; } } };
+ const tabSource = path.join(__dirname, '..', 'UI', 'tab_manager', 'tabman.js');
+ const { TabManager } = loadModule(tabSource, { confirm: () => true }, ['TabManager']);
+ const require = (_, callback) => { ready = callback(monaco); };
+ require.config = () => {};
+ const source = read(path.join(webDir, 'app.js')).replace(/^import .*;$/gm, '');
+ const context = vm.createContext({ window, document: appDocument, console, require, initIcons: async () => {}, getFileIcon: () => '', getFolderIcon() {}, getChevronIcon() {}, getUiIcon() {}, fetch: async () => ({ json: async () => ({ colors: {} }) }), TabManager: class extends TabManager { constructor(...args) { super(...args); tabs = this; } } });
+ await vm.runInContext(`(async () => { ${source}\n })()`, context);
+ await ready;
+ return { window, document: appDocument, tabs, editor, options, model, messages, start, change: () => onChange(), complete: () => complete.provideCompletionItems(), starts: () => starts };
+}
+
+const tick = async () => { await new Promise((resolve) => setImmediate(resolve)); };
+
+test('JSON tabs open independently and retain stable URIs, increasing versions, and tab-local diagnostics', async () => {
+ const app = await createApp();
+ const first = app.tabs.getActiveTab();
+ app.tabs.openTab('/workspace/second.json', app.model('{}', 'json'));
+ const second = app.tabs.getActiveTab();
+ app.start();
+ await tick();
+ const opens = () => app.messages.filter((msg) => msg.method === 'textDocument/didOpen');
+ assert.equal(app.starts(), 1);
+ assert.equal(opens().length, 2);
+ assert.ok(opens().every((msg) => msg.params.textDocument.version === 1));
+ assert.notEqual(opens()[0].params.textDocument.uri, opens()[1].params.textDocument.uri);
+ for (const tab of [first, second, first]) {
+ app.tabs.setActive(tab.id);
+ tab.model.setValue('{');
+ await app.change();
+ }
+ const changes = app.messages.filter((msg) => msg.method === 'textDocument/didChange');
+ assert.deepEqual(changes.map((msg) => msg.params.textDocument.version), [2, 2, 3]);
+ assert.equal(opens().length, 2);
+ app.options[1].listeners.click();
+ assert.equal(first.model.language, 'python');
+ app.tabs.setActive(second.id);
+ assert.equal(second.model.language, 'json');
+ app.tabs.setActive(first.id);
+ assert.equal(first.model.language, 'json');
+ app.tabs.openTab(null, app.model('', 'plaintext'), 'Untitled');
+ app.options[0].listeners.click();
+ await tick();
+ const untitledUri = opens().at(-1).params.textDocument.uri;
+ assert.match(untitledUri, /^untitled:/);
+ await app.change();
+ await app.complete();
+ assert.equal(app.messages.at(-1).params.textDocument.uri, untitledUri);
+ assert.equal(opens().length, 3);
+ assert.equal(first.model.markers.length, 1);
+ assert.equal(second.model.markers.length, 1);
+ app.tabs.openTab(null, app.model('{}', 'plaintext'), 'Another untitled');
+ app.options[0].listeners.click();
+ await tick();
+ assert.equal(opens().length, 4);
+ assert.notEqual(opens().at(-1).params.textDocument.uri, untitledUri);
+ assert.equal(opens().at(-1).params.textDocument.version, 1);
+});
+
+
+test('failed exports keep a tab dirty and successful exports assign a real path', async () => {
+ const app = await createApp();
+ app.start();
+ await tick();
+ const tab = app.tabs.getActiveTab();
+ tab.isDirty = true;
+ assert.equal(await app.tabs.onSave(tab), false);
+ assert.equal(tab.isDirty, true);
+ app.window.JSZip = class { file() {} async generateAsync() { return {}; } };
+ app.tabs.openTab(null, app.model('unsaved content', 'plaintext'), 'Untitled');
+ const untitled = app.tabs.getActiveTab();
+ untitled.isDirty = true;
+ assert.equal(await app.tabs.onSave(untitled), true);
+ assert.equal(untitled.isDirty, false);
+ assert.match(untitled.filePath, /\.txt$/);
+ assert.equal((await app.window.api.readFile(untitled.filePath)).content, 'unsaved content');
+});
diff --git a/docs/webIDE.test.js b/docs/webIDE.test.js
new file mode 100644
index 0000000..46afb93
--- /dev/null
+++ b/docs/webIDE.test.js
@@ -0,0 +1,47 @@
+const assert = require("node:assert/strict");
+const { existsSync, readFileSync } = require("node:fs");
+const path = require("node:path");
+const { test } = require("node:test");
+
+test("verifies Web IDE files and shared sync assets exist", () => {
+ const webIdeDir = path.join(__dirname, "webIDE");
+ assert.ok(existsSync(path.join(webIdeDir, "index.html")), "index.html should exist");
+ assert.ok(existsSync(path.join(webIdeDir, "style.css")), "style.css should exist");
+ assert.ok(existsSync(path.join(webIdeDir, "app.js")), "app.js should exist");
+ assert.ok(existsSync(path.join(webIdeDir, "web-api.js")), "web-api.js should exist");
+
+ const sharedDir = path.join(webIdeDir, "shared");
+ assert.ok(existsSync(path.join(sharedDir, "styles", "style1.css")), "shared style1.css should exist");
+ assert.ok(existsSync(path.join(sharedDir, "UI", "tab_manager", "tabman.js")), "shared TabManager should exist");
+ assert.ok(existsSync(path.join(sharedDir, "icons.js")), "shared icons.js should exist");
+ assert.ok(existsSync(path.join(sharedDir, "themes.js")), "shared themes.js should exist");
+});
+
+test("verifies web-api.js browser translation layer exports required window.api methods and ZIP save", () => {
+ const apiJs = readFileSync(path.join(__dirname, "webIDE", "web-api.js"), "utf8");
+
+ assert.match(apiJs, /window\.api\s*=\s*\{/);
+ assert.match(apiJs, /openFile:/);
+ assert.match(apiJs, /readFile:/);
+ assert.match(apiJs, /saveFile:/);
+ assert.match(apiJs, /saveProjectZip:/);
+ assert.match(apiJs, /listDirectory:/);
+ assert.match(apiJs, /openFolder:/);
+ assert.match(apiJs, /getSettings:/);
+ assert.match(apiJs, /setUserName:/);
+ assert.match(apiJs, /setTheme:/);
+ assert.match(apiJs, /setLanguage:/);
+ assert.match(apiJs, /startFixson:/);
+ assert.match(apiJs, /textDocument\/publishDiagnostics/);
+});
+
+test("verifies docs/webIDE/index.html includes JSZip, removes Git sidebar elements, and loads scripts", () => {
+ const htmlContent = readFileSync(path.join(__dirname, "webIDE", "index.html"), "utf8");
+
+ assert.match(htmlContent, /jszip/i);
+ assert.match(htmlContent, /
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+