Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
import { initializePreferencePage } from './preference/preference.mjs'
const pages = [
'index.html',
'flash/index.html',
'preference/index.html',
'editor/index.html',
'editor/tutorial.html',
'face-editor/index.html',
'simulator/index.html',
]
test('all web tools use the shared Japanese application shell', () => {
for (const page of pages) {
const html = readFileSync(page, 'utf8')
assert.match(html, /<html lang="ja">/, `${page} should declare Japanese UI text`)
assert.match(html, /class="topbar"/, `${page} should expose the shared top bar`)
assert.match(html, /stackchan-symbol\.png/, `${page} should expose the Stack-chan brand`)
assert.match(html, /apple-touch-icon/, `${page} should expose an Apple touch icon`)
assert.match(html, /data-lucide=/, `${page} should use Lucide controls`)
}
})
test('tool pages load the shared left drawer without changing integration ids', () => {
for (const page of pages) {
const html = readFileSync(page, 'utf8')
assert.match(html, /tool-navigation\.mjs/, `${page} should load shared tool navigation`)
assert.doesNotMatch(html, /class="tool-nav"/, `${page} should not duplicate the retired right navigation`)
}
const navigation = readFileSync('tool-navigation.mjs', 'utf8')
for (const id of ['home', 'flash', 'preference', 'simulator', 'editor', 'face-editor', 'tutorial']) {
assert.match(navigation, new RegExp(`id: '${id}'`), `drawer should expose ${id}`)
}
assert.match(navigation, /topbar\.prepend\(button\)/)
assert.match(navigation, /anchor\.dataset\.toolId = item\.id/)
assert.match(navigation, /dialog\.showModal\(\)/)
assert.match(navigation, /aria-current/)
const preference = readFileSync('preference/index.html', 'utf8')
for (const id of [
'ble-connect-button',
'ble-disconnect-button',
'form-preference',
'settings-form',
'wifi-clear-button',
]) {
assert.match(preference, new RegExp(`id="${id}"`))
}
const editor = readFileSync('editor/index.html', 'utf8')
for (const id of [
'blockly-workspace',
'build-button',
'download-button',
'install-simulator-button',
'install-device-button',
'project-name-display',
'project-name-label',
'project-menu-button',
'project-menu',
'recent-projects-button',
'recent-projects-submenu',
'recent-projects-list',
'face-selection-button',
'face-selection-label',
'face-selection-menu',
'face-selection-list',
'edit-face-button',
]) {
assert.match(editor, new RegExp(`id="${id}"`))
}
assert.match(editor, /role="tablist"/)
assert.match(editor, /class="target-device-control"[^>]*>[\s\S]*?data-lucide="cpu"[\s\S]*?id="target-device"/)
assert.match(editor, /id="recent-projects-button"[\s\S]*?最近開いたプロジェクト/)
assert.match(editor, /id="recent-projects-submenu"[\s\S]*?role="menu"/)
assert.doesNotMatch(editor, /<select[^>]+id="recent-projects"/)
assert.doesNotMatch(editor, /id="mobile-recent-projects"/)
assert.doesNotMatch(editor, /restore-device-button|restore-file-input|バックアップを復元/)
assert.match(editor, /id="recovery-button"[^>]*hidden/)
assert.match(
editor,
/class="build-section"[\s\S]*?id="build-button"[\s\S]*?class="asset-section"[\s\S]*?id="face-selection-button"[\s\S]*?id="asset-summary"[\s\S]*?id="embed-assets"[\s\S]*?class="output-section"/
)
assert.match(editor, /class="face-selection-heading">Face</)
assert.match(editor, /class="face-selection-state">使用中</)
assert.doesNotMatch(editor, /id="metrics-button"|評価ログを保存/)
assert.doesNotMatch(editor, /mobile-project-dialog/)
const editorScript = readFileSync('editor/editor.mjs', 'utf8')
assert.match(editorScript, /\.output-tabs \[role="tab"\]/)
assert.match(editorScript, /ArrowRight/)
assert.match(editorScript, /candidate\.tabIndex = selected \? 0 : -1/)
assert.match(editorScript, /label\.className = 'asset-chip-label'/)
assert.match(editorScript, /setProjectMenuOpen/)
assert.match(editorScript, /setRecentProjectsSubmenuOpen/)
assert.match(editorScript, /renderFaceSelection/)
assert.match(editorScript, /saveFaceEditContext/)
assert.match(editorScript, /faceEditorNavigationLink\?\.addEventListener/)
assert.match(editorScript, /window\.addEventListener\('pageshow', renderFaceSelection\)/)
assert.match(editorScript, /role', 'menuitemradio'/)
assert.match(editorScript, /startProjectNameEdit/)
assert.doesNotMatch(editorScript, /use\.textContent = selected \? '使用中' : '使う'/)
assert.doesNotMatch(editorScript, /createMetricsReport|metricsButton|onBackup|restoreDeviceButton/)
assert.match(editorScript, /互換性を確認しました/)
const deviceWriteFlow = editorScript.slice(
editorScript.indexOf('async function writeArchiveToDevice'),
editorScript.indexOf("installDeviceButton.addEventListener('click'")
)
assert.doesNotMatch(deviceWriteFlow, /globalThis\.(?:confirm|alert)|onBackup/)
const installerScript = readFileSync('editor/esptool-installer.mjs', 'utf8')
assert.doesNotMatch(installerScript, /onBackup|現在のMODをバックアップ|削除前のMOD/)
const editorStyles = readFileSync('editor/editor.css', 'utf8')
assert.match(editorStyles, /\.blocklyTreeLabel\s*{[^}]*color:\s*#202428/s)
assert.match(editorStyles, /\.blocklyDraggable:hover[^}]*cursor:\s*pointer\s*!important/s)
assert.match(editorStyles, /\.asset-section,\s*\.build-section\s*{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/s)
assert.match(editorStyles, /\.asset-section\s*{[^}]*background:\s*var\(--surface-raised/s)
assert.match(editorStyles, /\.asset-chip-label\s*{[^}]*text-overflow:\s*ellipsis/s)
assert.match(editorStyles, /\.face-selection-button\s*{[^}]*width:\s*100%/s)
assert.match(editorStyles, /\.face-selection-menu\s*{[^}]*position:\s*absolute/s)
assert.match(editorStyles, /\.project-name-display:hover svg/)
assert.match(editorStyles, /\.project-menu\s*{[^}]*position:\s*absolute/s)
assert.match(editorStyles, /\.project-submenu\s*{[^}]*right:\s*100%/s)
assert.match(
editorStyles,
/@media \(max-width:\s*760px\)[\s\S]*?\.editor-layout\s*{[^}]*grid-template-rows:\s*minmax\(480px,\s*64vh\)\s*auto/s
)
assert.match(editorStyles, /@media \(max-width:\s*760px\)[\s\S]*?\.project-submenu\s*{[^}]*position:\s*static/s)
})
class FakeElement {
constructor({ id, name = '' }) {
this.id = id
this.name = name
this.value = ''
this.hidden = false
this.disabled = false
this.textContent = ''
this.dataset = {}
this.controls = []
this.listeners = new Map()
}
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? []
listeners.push(listener)
this.listeners.set(type, listeners)
}
querySelectorAll() {
return this.controls
}
async dispatch(type, target = this) {
const event = { target, preventDefault() {} }
for (const listener of this.listeners.get(type) ?? []) await listener(event)
}
async click() {
await this.dispatch('click')
}
}
class FakeDocument {
constructor(elements) {
this.elements = new Map(elements.map((element) => [element.id, element]))
}
getElementById(id) {
return this.elements.get(id) ?? null
}
querySelector(selector) {
const name = selector.match(/^\[name="(.+)"\]$/)?.[1]
if (name == null) return null
return [...this.elements.values()].find((element) => element.name === name) ?? null
}
}
class FakePreferenceClient {
connected = true
messages = []
shouldFail = false
sendError = null
isConnected() {
return this.connected
}
async connect() {
this.connected = true
}
async disconnect() {
this.connected = false
}
async send(message) {
this.messages.push(message)
if (this.shouldFail) throw this.sendError
}
}
function createPreferencePage() {
const form = new FakeElement({ id: 'form-preference' })
const connect = new FakeElement({ id: 'ble-connect-button' })
const disconnect = new FakeElement({ id: 'ble-disconnect-button' })
const submit = new FakeElement({ id: 'preference-submit-button' })
const clear = new FakeElement({ id: 'wifi-clear-button' })
const connectionStatus = new FakeElement({ id: 'connection-status' })
const saveStatus = new FakeElement({ id: 'save-status' })
const ssid = new FakeElement({ id: 'wifi.ssid', name: 'wifi.ssid' })
const password = new FakeElement({ id: 'wifi.password', name: 'wifi.password' })
const driverType = new FakeElement({ id: 'driver.type', name: 'driver.type' })
const aiToken = new FakeElement({ id: 'ai.token', name: 'ai.token' })
driverType.value = 'm5stackchan'
form.controls = [ssid, password, driverType, aiToken, clear, submit]
const document = new FakeDocument([
form,
connect,
disconnect,
submit,
clear,
connectionStatus,
saveStatus,
ssid,
password,
driverType,
aiToken,
])
return { document, form, submit, clear, saveStatus, ssid, password, driverType, aiToken }
}
test('preference page displays and locks the effective M5StackChan driver reported over BLE', () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
const { onCharacteristicValueChanged } = initializePreferencePage({ document: page.document, client })
page.driverType.value = 'scservo'
onCharacteristicValueChanged({ prop: 'driver.type', value: 'm5stackchan', readOnly: true })
assert.equal(page.driverType.value, 'm5stackchan')
assert.equal(page.driverType.disabled, true)
})
test('saving another field never sends an untouched driver HTML default', async () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
initializePreferencePage({ document: page.document, client })
page.driverType.value = 'scservo'
page.ssid.value = 'new-ap'
await page.form.dispatch('input', page.ssid)
await page.form.dispatch('submit')
assert.deepEqual(client.messages, [{ _batch: { 'wifi.ssid': 'new-ap' } }])
})
test('explicitly selecting generic SCServo sends only the driver change', async () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
const { onCharacteristicValueChanged } = initializePreferencePage({ document: page.document, client })
onCharacteristicValueChanged({ prop: 'driver.type', value: 'm5stackchan' })
page.driverType.value = 'scservo'
await page.form.dispatch('change', page.driverType)
await page.form.dispatch('submit')
assert.deepEqual(client.messages, [{ _batch: { 'driver.type': 'scservo' } }])
})
test('explicitly clearing an optional text field sends the empty value', async () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
const { onCharacteristicValueChanged } = initializePreferencePage({ document: page.document, client })
onCharacteristicValueChanged({ prop: 'ai.token', value: 'stored-token' })
page.aiToken.value = ''
await page.form.dispatch('input', page.aiToken)
await page.form.dispatch('submit')
assert.deepEqual(client.messages, [{ _batch: { 'ai.token': '' } }])
})
test('Wi-Fi clear confirms, sends both empty credentials, updates fields, and restores controls', async () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
page.ssid.value = 'stored-ap'
page.password.value = 'stored-secret'
let confirmations = 0
initializePreferencePage({
document: page.document,
client,
confirm: () => {
confirmations += 1
return true
},
})
await page.clear.click()
assert.equal(confirmations, 1)
assert.deepEqual(client.messages, [{ _batch: { 'wifi.ssid': '', 'wifi.password': '' } }])
assert.equal(page.ssid.value, '')
assert.equal(page.password.value, '')
assert.equal(page.saveStatus.dataset.state, 'success')
assert.match(page.saveStatus.textContent, /Wi-Fi設定を消去しました/)
assert.equal(page.clear.disabled, false)
assert.equal(page.submit.disabled, false)
})
test('Wi-Fi clear safely reports a null rejection and restores controls', async () => {
const page = createPreferencePage()
const client = new FakePreferenceClient()
client.shouldFail = true
client.sendError = null
const originalConsoleError = console.error
console.error = () => {}
try {
initializePreferencePage({ document: page.document, client, confirm: () => true })
await page.clear.click()
} finally {
console.error = originalConsoleError
}
assert.equal(page.saveStatus.dataset.state, 'error')
assert.match(page.saveStatus.textContent, /Wi-Fi設定を消去できませんでした: null/)
assert.equal(page.clear.disabled, false)
assert.equal(page.submit.disabled, false)
})
test('shared controls preserve the native hidden state', () => {
const css = readFileSync('global.css', 'utf8')
assert.match(css, /\[hidden\]\s*{[^}]*display:\s*none\s*!important/s)
})
test('flash tool exposes the M5StackChan CoreS3 firmware target', () => {
const html = readFileSync('flash/index.html', 'utf8')
assert.match(html, /<option value="esp32_m5stackchan_cores3">M5StackChan CoreS3<\/option>/)
assert.doesNotMatch(html, /<option value="esp32_m5stack_fire">/)
assert.match(html, /`manifest_\$\{event\.target\.value\}\.json`/)
const manifest = JSON.parse(readFileSync('flash/manifest_esp32_m5stackchan_cores3.json', 'utf8'))
assert.equal(manifest.builds.length, 1)
assert.equal(manifest.builds[0].chipFamily, 'ESP32-S3')
assert.deepEqual(manifest.builds[0].parts, [
{
path: 'tech.moddable.stackchan/m5stackchan_cores3/bootloader.bin',
offset: 0,
},
{
path: 'tech.moddable.stackchan/m5stackchan_cores3/partition-table.bin',
offset: 32768,
},
{
path: 'tech.moddable.stackchan/m5stackchan_cores3/xs_esp32.bin',
offset: 65536,
},
])
})
test('third-party scripts that handle editable data use subresource integrity', () => {
for (const page of ['preference/index.html', 'editor/index.html', 'face-editor/index.html']) {
const html = readFileSync(page, 'utf8')
const externalScripts = [...html.matchAll(/<script[^>]+src="https:\/\/unpkg\.com\/[^>]+>/g)]
assert.notEqual(externalScripts.length, 0)
for (const [script] of externalScripts) {
assert.match(script, /integrity="sha384-/, `${page} external script should have SRI`)
assert.match(script, /crossorigin="anonymous"/, `${page} external script should use anonymous CORS`)
}
}
})
9a3c37939This file is pinned to the selected release. Download the original to open it in your design tool.