From 35c2a0f3cf998bf8e2676f79b1adceaf5fce619e Mon Sep 17 00:00:00 2001 From: "STARAI\\Star" Date: Sun, 13 Oct 2024 23:12:55 +0800 Subject: [PATCH] 1 --- .gitignore | 5 + LICENSE | 9 ++ README.md | 70 +++++++++ app.js | 67 +++++++++ cgo.yml | 3 + client.go | 334 +++++++++++++++++++++++++++++++++++++++++++ client.ts | 28 ++++ go.mod | 25 ++++ include/EventToken.h | 25 ++++ plugin_run.go | 31 ++++ runTest.bat | 3 + tests/build | 86 +++++++++++ tests/client.html | 32 +++++ tests/client.js | 8 ++ tests/client_test.go | 29 ++++ tests/logo.png | Bin 0 -> 6275 bytes 16 files changed, 755 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.js create mode 100644 cgo.yml create mode 100644 client.go create mode 100644 client.ts create mode 100644 go.mod create mode 100644 include/EventToken.h create mode 100644 plugin_run.go create mode 100644 runTest.bat create mode 100644 tests/build create mode 100644 tests/client.html create mode 100644 tests/client.js create mode 100644 tests/client_test.go create mode 100644 tests/logo.png diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..867e853 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.* +!.gitignore +go.sum +node_modules +package.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d894367 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2024 apigo + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c4a89cb --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# util for GoJS + +## usage + +```go +import ( + "apigo.cc/gojs" + _ "apigo.cc/gojs/util" +) + +func main() { + r, err := gojs.Run(` +import util from 'apigo.cc/gojs/util' + +function main(args){ + return util.sha512('hello 123') +} + `, "test.js") + + fmt.Println(r, err, r == "27d0d415a3b14bdeb5ab5b2298ed3aed272361688927250eaef201603b84dd8bebb57cae089d6b5f1e9aee122a67917ccdf8a9ba5e499c64b35e133c7f6b007a") +} +``` + +## module.exports + +```ts +function json(data:any) +function jsonP(data:any) +function unJson(data:string) +function yaml(data:any) +function unYaml(data:string) +function load(filename:string) +function save(filename:string, data:any) +function base64(data:any) +function unBase64(data:string) +function urlBase64(data:any) +function unUrlBase64(data:string) +function hex(data:any) +function unHex(data:string) +function aes(data:any, key:string, iv:string) +function unAes(data:string, key:string, iv:string) +function gzip(data:any) +function gunzip(data:string) +function id() +function uniqueId() +function token(size:number) +function md5(data:any) +function sha1(data:any) +function sha256(data:any) +function sha512(data:any) +function tpl(text:string, data:any, functions?:Object) +function sleep(ms:number) +function setTimeout(callback:()=>void, ms?:number, ...args:any) +function shell(cmd:string, ...args:string[]) +function toDatetime(timestamp:number) +function fromDatetime(datetimeStr:string) +function toDate(timestamp:number) +function fromDate(dateStr:string) +function os() +function arch() +function joinPath(...paths:string[]) +function getPathDir(path:string) +function getPathBase(path:string) +function getPathVolume(path:string) +function absPath(path:string) +function cleanPath(path:string) +function isLocalPath(path:string) +``` + +## full api see [util.ts](https://apigo.cc/gojs/util/util.ts) diff --git a/app.js b/app.js new file mode 100644 index 0000000..4e3eca2 --- /dev/null +++ b/app.js @@ -0,0 +1,67 @@ +window._dialogTexts = { 'zh': { 'Close': '关闭', 'Cancel': '取消', 'Confirm': '确定' } } +function _getDialogDefaultText(text) { return (window._dialogTexts[navigator.language] && window._dialogTexts[navigator.language][text]) || (window._dialogTexts[navigator.language.split('-')[0]] && window._dialogTexts[navigator.language.split('-')[0]][text]) || text } +window.alert = function (msg, buttonText) { return showDialog(msg, [buttonText || _getDialogDefaultText('Close')]) } +window.confirm = function (msg, confirmButtonText, cancelButtonText) { return showDialog(msg, [cancelButtonText || _getDialogDefaultText('Cancel'), cancelButtonText || _getDialogDefaultText('Confirm')]) } +window.prompt = function (msg, defaultValue, confirmButtonText, cancelButtonText) { return showDialog(msg, [cancelButtonText || _getDialogDefaultText('Cancel'), cancelButtonText || _getDialogDefaultText('Confirm')], [{ value: defaultValue || '' }]) } +window.showDialog = function (msg, buttons, inputs) { + if (!document.querySelector('#_dialogStyle')) { + let sDom = document.createElement('style') + sDom.id = '_dialogStyle' + sDom.textContent = '._dialogMask {position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(128,128,128,0.7);display:flex;justify-content:center;align-items:center}\n' + + '._dialogPanel {background:#eee;padding:16px;min-width:300px;max-width:80%;max-height:80%;margin:auto;border-radius:10px;display:flex;flex-flow:column;overflow:auto}\n' + + '._dialogMessage {font-size:0.875rem;flex:1;overflow:auto;white-space:pre-wrap}\n' + + '._dialogInputs {display:flex;flex-flow:column;margin-top:8px}\n' + + '._dialogInputs input {flex:1;border:1px solid #aaa;border-radius:4px;min-height:28px;padding:0 8px}\n' + + '._dialogLine {text-align:end;padding:4px 0}\n' + + '._dialogButtons {text-align:end}\n' + + '._dialogButtons button {background:none;border:none;color:#999;min-height:28px;cursor:pointer;font-size:1rem;margin-left:10px}\n' + + '._dialogButtons button:hover {color:#999}\n' + + '._dialogButtons button:last-child {color:#06f}' + document.head.append(sDom) + } + return new Promise(resolve => { + let a = ['
'] + if (msg) a.push('
__MSG__
'.replace(/__MSG__/, msg)) + if (inputs && inputs.length) { + let a1 = ['
'] + for (let i = 0; i < inputs.length; i++) { + a1.push(''.replace(/__INPUT_INDEX__/, i + '').replace(/__INPUT_HINT__/, inputs[i].hint || '').replace(/__INPUT_VALUE__/, inputs[i].value || '')) + } + a1.push('
') + a.push(a1.join('')) + } + a.push('

') + let a2 = ['
'] + for (let i = 0; i < buttons.length; i++) a2.push(''.replace(/__BUTTON_INDEX__/, i + '').replace(/__BUTTON_LABEL__/, buttons[i])) + a2.push('
') + a.push(a2.join('')) + let dialogId = Math.ceil(Math.random() * 10000000000).toString(36) + let w = document.createElement('div') + w.id = '_dialogWindow' + dialogId + w.className = '_dialogMask' + window['_dialogAction' + dialogId] = function (index) { + let isOK = index === buttons.length - 1 + if (inputs && inputs.length) { + if (isOK) { + let inputValues = [] + for (let i = 0; i < inputs.length; i++) inputValues.push(document.querySelector('#_dialogInput' + dialogId + '_' + i).value) + resolve(inputs.length === 1 ? inputValues[0] : inputValues) + } else { + resolve(false) + } + } else { + resolve(isOK ? true : index) + } + document.body.removeChild(document.querySelector('#_dialogWindow' + dialogId)) + } + w.innerHTML = a.join('').replace(/__ID__/g, dialogId) + document.body.append(w) + }) +} + +let app = { + close: __closeWindow, + setTitle: __setTitle, + setSize: __setSize, + eval: __eval, +} diff --git a/cgo.yml b/cgo.yml new file mode 100644 index 0000000..853fe84 --- /dev/null +++ b/cgo.yml @@ -0,0 +1,3 @@ +CGO_ENABLED: true +CGO_CPPFLAGS: + - -I%PROJECT%/include diff --git a/client.go b/client.go new file mode 100644 index 0000000..1ea55e8 --- /dev/null +++ b/client.go @@ -0,0 +1,334 @@ +package client + +import ( + _ "embed" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "apigo.cc/gojs" + "apigo.cc/gojs/goja" + "github.com/ssgo/tool/watcher" + webview "github.com/webview/webview_go" +) + +//go:embed client.ts +var clientTS string + +//go:embed README.md +var clientMD string + +//go:embed app.js +var appCode string + +var binds = map[string]any{} +var bindsLock = sync.RWMutex{} + +var windowsId uint64 +var windows = map[uint64]*Webview{} +var windowsLock = sync.RWMutex{} +var waitChan chan bool + +func Bind(name string, fn any) { + bindsLock.Lock() + binds[name] = fn + bindsLock.Unlock() +} + +func Unbind(name string) { + bindsLock.Lock() + delete(binds, name) + bindsLock.Unlock() +} + +type Webview struct { + id uint64 + w webview.WebView + isRunning bool + isAsync bool + isDebug bool + webRoot string + watcher *watcher.Watcher + html string + url string + closeChan chan bool +} + +func Wait() { + // 如果有窗口在运行,等待窗口关闭 + windowsLock.RLock() + if len(windows) > 0 { + waitChan = make(chan bool, 1) + } + windowsLock.RUnlock() + + // fmt.Println("====== wait start") + if waitChan != nil { + <-waitChan + waitChan = nil + } + // fmt.Println("====== wait end") +} + +func CloseAll() { + windows1 := map[uint64]*Webview{} + windowsLock.Lock() + for id, w := range windows { + windows1[id] = w + } + windowsLock.Unlock() + for _, w := range windows1 { + w.close() + } +} + +func (w *Webview) close() { + // fmt.Println("====== w.Close()") + if w.isRunning { + w.isRunning = false + // w.closeChan = make(chan bool, 1) + // fmt.Println("====== w.w.Terminate()") + w.w.Terminate() + // fmt.Println("====== w.w.Terminate() ok") + // <-w.closeChan + // w.closeChan = nil + } +} + +func (w *Webview) doClose() { + // fmt.Println("====== w.doClose()") + if w.watcher != nil { + w.watcher.Stop() + w.watcher = nil + } + windowsLock.Lock() + delete(windows, w.id) + n := len(windows) + windowsLock.Unlock() + + // fmt.Println("====== w.w.Destroy()") + w.w.Destroy() + // fmt.Println("====== w.w.Destroy() ok") + + // 解除 w.Close 的等待 + if w.closeChan != nil { + w.closeChan <- true + } + + // 解除 gojs.WaitAll 的等待 + if waitChan != nil && n == 0 { + waitChan <- true + } +} + +func (w *Webview) loadHtml(html string) { + w.w.SetHtml(html) +} + +func (w *Webview) loadURL(url string) { + if url != "" { + if strings.Contains(url, "://") { + w.w.Navigate(url) + } else { + if !filepath.IsAbs(url) { + curPath, _ := os.Getwd() + url = filepath.Join(curPath, url) + } + w.webRoot = filepath.Dir(url) + url = "file://" + url + w.w.Navigate(url) + } + } + w.w.Navigate(url) +} + +func (w *Webview) reload() { + if w.html != "" { + w.loadHtml(w.html) + } else { + w.loadURL(w.url) + } +} + +func (w *Webview) setTitle(title string) { + w.w.SetTitle(title) +} + +func (w *Webview) setSize(width int, height int, sizeMode string) { + w.w.SetSize(width, height, getSizeMode(sizeMode)) +} + +func (w *Webview) eval(code string) { + w.w.Eval(code) +} + +func (w *Webview) Close(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + w.close() + return nil +} + +func (w *Webview) LoadHtml(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadHtml(args.Str(0)) + return nil +} + +func (w *Webview) LoadURL(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.loadURL(args.Str(0)) + return nil +} + +func (w *Webview) Dispatch(f func()) { + w.w.Dispatch(f) +} + +func (w *Webview) SetTitle(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.setTitle(args.Str(0)) + return nil +} + +func (w *Webview) SetSize(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(2) + w.setSize(args.Int(0), args.Int(1), args.Str(2)) + return nil +} + +func (w *Webview) Eval(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm).Check(1) + w.eval(args.Str(0)) + return nil +} + +func (w *Webview) binds() { + tmpBinds := map[string]any{} + bindsLock.RLock() + for k, v := range binds { + tmpBinds[k] = v + } + bindsLock.RUnlock() + for k, v := range tmpBinds { + _ = w.w.Bind(k, v) + } + _ = w.w.Bind("__closeWindow", w.close) + _ = w.w.Bind("__setTitle", w.setTitle) + _ = w.w.Bind("__setSize", w.setSize) + _ = w.w.Bind("__eval", w.eval) + w.w.Init(appCode) +} + +func getSizeMode(sizeMode string) webview.Hint { + switch sizeMode { + case "none": + return webview.HintNone + case "fixed": + return webview.HintFixed + case "min": + return webview.HintMin + case "max": + return webview.HintMax + default: + return webview.HintNone + } +} + +func init() { + obj := map[string]any{ + "open": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + args := gojs.MakeArgs(&argsIn, vm) + title := "" + width := 800 + height := 600 + sizeMode := "none" + isDebug := false + html := "" + url := "" + if opt := args.Obj(0); opt != nil { + if titleV := opt.Str("title"); titleV != "" { + title = titleV + } + if widthV := opt.Int("width"); widthV != 0 { + width = widthV + } + if heightV := opt.Int("height"); heightV != 0 { + height = heightV + } + if sizeModeV := opt.Str("sizeMode"); sizeModeV != "" { + sizeMode = sizeModeV + } + if isDebugV := opt.Bool("isDebug"); isDebugV { + isDebug = isDebugV + } + if htmlV := opt.Str("html"); htmlV != "" { + html = htmlV + } + if urlV := opt.Str("url"); urlV != "" { + url = urlV + if strings.HasPrefix(url, "file://") { + url = url[7:] + } + } + } + + w := &Webview{isDebug: isDebug, html: html, url: url} + startChan := make(chan bool, 1) + go func() { + windowsLock.Lock() + w.id = windowsId + windowsId++ + windows[w.id] = w + windowsLock.Unlock() + + w.w = webview.New(isDebug) + w.w.SetTitle(title) + w.w.SetSize(width, height, getSizeMode(sizeMode)) + w.binds() + + if w.isDebug { + var isWaitingRun = false + if w.webRoot == "" && w.html != "" { + w.webRoot = "." + } + if w.webRoot != "" { + w.watcher, _ = watcher.Start([]string{w.webRoot}, []string{"html", "js", "css"}, func(filename string, event string) { + if !isWaitingRun { + isWaitingRun = true + go func() { + time.Sleep(time.Millisecond * 10) + isWaitingRun = false + w.w.Eval("location.reload()") + }() + } + }) + } + } + + w.isRunning = true + + w.reload() + startChan <- true + w.w.Run() + // fmt.Println("====== w.w.Run() end") + w.doClose() + }() + + <-startChan + return vm.ToValue(gojs.MakeMap(w)) + }, + "closeAll": func(argsIn goja.FunctionCall, vm *goja.Runtime) goja.Value { + CloseAll() + return nil + }, + } + + gojs.Register("apigo.cc/gojs/client", gojs.Module{ + Object: obj, + Desc: "web client framework by github.com/webview/webview", + TsCode: clientTS, + Example: clientMD, + WaitForStop: Wait, + }) +} diff --git a/client.ts b/client.ts new file mode 100644 index 0000000..f592944 --- /dev/null +++ b/client.ts @@ -0,0 +1,28 @@ +// just for develop + +export default { + open, + closeAll +} + +function open(config: Config): View { return null as any } +function closeAll(): void { } + +interface Config { + title: string + width: number + height: number + sizeMode: string + isDebug: boolean + html: string + url: string +} + +interface View { + close(): void + setTitle(title: string): void + setSize(width: number, height: number, sizeMode?: string): void + loadUrl(url: string): void + loadHtml(html: string): void + eval(code: string): void +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ae9f345 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module apigo.cc/gojs/client + +go 1.18 + +require ( + apigo.cc/gojs v0.0.1 + github.com/ssgo/tool v0.4.27 + github.com/ssgo/u v1.7.9 + github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 +) + +require ( + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/ssgo/config v1.7.7 // indirect + github.com/ssgo/log v1.7.7 // indirect + github.com/ssgo/standard v1.7.7 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace apigo.cc/gojs v0.0.1 => ../gojs diff --git a/include/EventToken.h b/include/EventToken.h new file mode 100644 index 0000000..c0a9dec --- /dev/null +++ b/include/EventToken.h @@ -0,0 +1,25 @@ +#ifndef WEBVIEW_COMPAT_EVENTTOKEN_H +#define WEBVIEW_COMPAT_EVENTTOKEN_H +#ifdef _WIN32 + +// This compatibility header provides types used by MS WebView2. This header can +// be used as an alternative to the "EventToken.h" header normally provided by +// the Windows SDK. Depending on the MinGW distribution, this header may not be +// present, or it may be present with the name "eventtoken.h". The letter casing +// matters when cross-compiling on a system with case-sensitive file names. + +#ifndef __eventtoken_h__ + +#ifdef __cplusplus +#include +#else +#include +#endif + +typedef struct EventRegistrationToken { + int64_t value; +} EventRegistrationToken; +#endif // __eventtoken_h__ + +#endif // _WIN32 +#endif // WEBVIEW_COMPAT_EVENTTOKEN_H \ No newline at end of file diff --git a/plugin_run.go b/plugin_run.go new file mode 100644 index 0000000..c67439f --- /dev/null +++ b/plugin_run.go @@ -0,0 +1,31 @@ +package client + +// import ( +// "apigo.cc/apigo/gojs" +// "current-plugin" +// "fmt" +// "github.com/ssgo/u" +// "os" +// "strings" +// ) + +// func main() { +// testOK := false +// client.Bind("setTestOK", func(testIsOK bool) { +// testOK = testIsOK +// }) + +// if files, err := os.ReadDir("."); err == nil { +// for _, f := range files { +// if !f.IsDir() && strings.HasSuffix(f.Name(), "_test.js") { +// testName := f.Name()[0 : len(f.Name())-8] +// r, err := gojs.RunFile(f.Name(), nil) +// if err != nil || r != true || !testOK { +// fmt.Println(u.BRed("test "+testName+" failed"), r, err) +// } else { +// fmt.Println(u.Green("test "+testName), u.BGreen("OK")) +// } +// } +// } +// } +// } diff --git a/runTest.bat b/runTest.bat new file mode 100644 index 0000000..6e6dece --- /dev/null +++ b/runTest.bat @@ -0,0 +1,3 @@ +set CGO_CPPFLAGS="-I%cd%\include" +cd tests +go test -v -count=1 -ldflags="-H windowsgui" . \ No newline at end of file diff --git a/tests/build b/tests/build new file mode 100644 index 0000000..0e1643c --- /dev/null +++ b/tests/build @@ -0,0 +1,86 @@ +#!/bin/bash + +# 设置默认值 +OS=${1:-linux} +ARCH=${2:-amd64} +EXT="" +LDFLAGS="" + +# 根据目标操作系统和架构设置编译参数 +case "$OS" in + windows) + GOOS=windows + ;; + mac) + GOOS=darwin + ;; + darwin) + GOOS=darwin + ;; + linux) + GOOS=linux + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +case "$ARCH" in + x64) + GOARCH=amd64 + ;; + amd64) + GOARCH=amd64 + ;; + x86) + GOARCH=386 + ;; + 386) + GOARCH=386 + ;; + arm64) + GOARCH=arm64 + ;; + arm) + GOARCH=arm + ;; + *) + echo "Unsupported ARCH: $ARCH" + exit 1 + ;; +esac + +# 启用 CGO +export CGO_ENABLED=1 + +# 设置交叉编译工具链(如果需要) +if [ "$GOOS" = "windows" ]; then + EXT=.exe + CC=x86_64-w64-mingw32-gcc + CXX=x86_64-w64-mingw32-g++ + LDFLAGS="-H windowsgui" + PWD=`pwd` + CGO_CPPFLAGS="-I${PWD}/include" +elif [ "$GOOS" = "darwin" ]; then + CC=o64-clang + CXX=o64-clang++ +else + CC=gcc + CXX=g++ +fi + +export CC + +# 编译 +echo "Building for $GOOS/$GOARCH..." + +# mkdir -p build +GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" CXX="${CXX}" CGO_CPPFLAGS="${CGO_CPPFLAGS}" go build -o "dist/${GOOS}_${GOARCH}${EXT}" -ldflags="${LDFLAGS}" . + +if [ $? -eq 0 ]; then + echo "Build successful: dist/${GOOS}_${GOARCH}${EXT}" +else + echo "Build failed" + exit 1 +fi diff --git a/tests/client.html b/tests/client.html new file mode 100644 index 0000000..366c4c3 --- /dev/null +++ b/tests/client.html @@ -0,0 +1,32 @@ + + + + + + + Title + + + + + +

Hello, world!

+
+ +
+ + + \ No newline at end of file diff --git a/tests/client.js b/tests/client.js new file mode 100644 index 0000000..f4c8bcb --- /dev/null +++ b/tests/client.js @@ -0,0 +1,8 @@ +import client from 'apigo.cc/gojs/client' + +client.open({ + width: 800, + height: 600, + title: 'Hello', + url: 'client.html', +}) diff --git a/tests/client_test.go b/tests/client_test.go new file mode 100644 index 0000000..a09b880 --- /dev/null +++ b/tests/client_test.go @@ -0,0 +1,29 @@ +package client_test + +import ( + "fmt" + "testing" + + "apigo.cc/gojs" + "apigo.cc/gojs/client" +) + +func TestClient(t *testing.T) { + gojs.ExportForDev() + testOK := false + client.Bind("setTestOK", func(testIsOK bool) { + testOK = testIsOK + }) + + r, err := gojs.RunFile("client.js") + if err != nil { + t.Fatal(err) + } + gojs.WaitAll() + + if !testOK { + t.Fatal("test failed") + } else { + fmt.Println("test OK", r) + } +} diff --git a/tests/logo.png b/tests/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1c41a86a501c17d16374b04d7f45150b1c19932f GIT binary patch literal 6275 zcmV-}7<}i6P) z&u<$^cE?{!@;DQ$CC)fOl9MT5y_=iqQ;w$p0#oZ4;_KY;0`Y!j&Ayso*P~=aum1RIm~@OiuAO4eg2sx1`cLPfS>>T=T%akdmb|dA4^zfP(o4m z^y$-GK3WWLFiT8aW{a0EUv41^S47H^efkKc_0dNkJ+^g=9u8(PMAa2<-n@C}4TO

i$-Tf{l5I~2{*JYIM#VL+mX18y@{kDRL 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