@@ -0,0 +1,608 @@
1+package nodejs
2+
3+import (
4+ "bytes"
5+ "fmt"
6+ "io"
7+ "os"
8+ "os/exec"
9+ "path"
10+ "strings"
11+ "sync"
12+ "testing"
13+ "time"
14+
15+ "github.com/creack/pty"
16+ "golang.org/x/sys/unix"
17+)
18+
19+// There's a good chance parts of this break if we upgrade node.
20+// This is probably just a slight change to escape codes which human eyes won't see.
21+// If it does happen, all you need to do to fix it is:
22+//
23+// 1. open a repl the same way that the test would.
24+// 2. simulate the test. Make sure everything looks like it's suppposed to.
25+// 3. If everything looks OK, it's probably fine to just copy + paste the output from "Received: "
26+// directly into the test's expected output.
27+//
28+// Some of the particular things to check are:
29+// - prompt / tab suggestions (though this isn't currently tested, it hopefully will be in the future)
30+// - preview (should evaluate any expression which can't possibly have any side effects and display a truncated version of it below the prompt)
31+// - results of expressions - every line should be evaulated, with the result of that line being printed to stdout
32+// - any errors should be displayed in red.
33+//
34+// If you're adding new tests, the process is pretty similar:
35+// 1. Verfiy that the test case you have works as expected outside of tests.
36+// 2. Create the test entry, you can leave the output as `tbd` or similar
37+// 3. Double check that the test case works normally, run tests, then copy the received output as the expected output.
38+// 4. Write some comments above that test describing what the expected value looks like (see existing tests for examples)
39+
40+var prybarAssetsPath string
41+
42+func init() {
43+ cwd, err := os.Getwd()
44+ if err != nil {
45+ panic(err)
46+ }
47+
48+ prybarAssetsPath = path.Join(cwd, "../../prybar_assets/")
49+
50+ fmt.Println(prybarAssetsPath)
51+}
52+
53+func getBasicArgs(isQuiet, isInteractive bool) []string {
54+ if isQuiet && isInteractive {
55+ return []string{"-q", "-i"}
56+ } else if isQuiet {
57+ return []string{"-q"}
58+ } else if isInteractive {
59+ return []string{"-i"}
60+ }
61+
62+ return []string{}
63+}
64+
65+func getCodeArgs(
66+ isQuiet,
67+ isInteractive bool,
68+ code string,
69+) []string {
70+ return append(getBasicArgs(isQuiet, isInteractive), "-c", code)
71+}
72+
73+func getFileArgs(t *testing.T, isQuiet, isInteractive bool, fileContent string) []string {
74+ file, err := os.CreateTemp("", "prybar_test_*.js")
75+
76+ if err != nil {
77+ panic(err)
78+ }
79+
80+ t.Cleanup(func() {
81+ os.Remove(file.Name())
82+ })
83+
84+ file.WriteString(fileContent)
85+
86+ return append(getBasicArgs(isQuiet, isInteractive), file.Name())
87+
88+}
89+
90+type testCase struct {
91+ name string
92+ input string
93+ expected string
94+}
95+
96+type testInfo struct {
97+ name string
98+ prefix string
99+ args []string
100+}
101+
102+func trimNewlines(str string) string {
103+
104+ return str[1 : len(str)-1]
105+}
106+
107+// used to format a string in the format of:
108+// ```go
109+//`
110+//<input>
111+//`
112+// into a string with '\\x1b' translated to '\x1b' and without the leading + trailing newlines.
113+// only needed for multiline strings.
114+func unescapeExpectedOutput(str string) string {
115+ str = trimNewlines(str)
116+ str = strings.ReplaceAll(str, "\\r", "\r")
117+ return strings.ReplaceAll(str, "\\x1b", "\x1b")
118+}
119+
120+func escape(str string) string {
121+ return strings.ReplaceAll(str, "\x1b", "\\x1b")
122+}
123+
124+type delayedReader struct {
125+ reader io.Reader
126+ once sync.Once
127+}
128+
129+func (r *delayedReader) Read(buf []byte) (int, error) {
130+ r.once.Do(func() {
131+ time.Sleep(50 * time.Millisecond)
132+ })
133+
134+ return r.reader.Read(buf)
135+}
136+
137+func (tc testCase) run(t *testing.T, info testInfo) {
138+ t.Parallel()
139+ input := bytes.NewBufferString(tc.input)
140+ output := &bytes.Buffer{}
141+
142+ cmd := exec.Command("../../prybar-nodejs", info.args...)
143+
144+ cmd.Env = append(os.Environ(), "PRYBAR_ASSETS_DIR="+prybarAssetsPath)
145+
146+ ppty, tty, err := pty.Open()
147+
148+ if err != nil {
149+ t.Fatal(err)
150+ }
151+
152+ defer tty.Close()
153+ defer ppty.Close()
154+
155+ // ensure that we get a constant size for the pty.
156+ pty.Setsize(ppty, &pty.Winsize{
157+ Cols: 100,
158+ Rows: 100,
159+ })
160+
161+ cmd.Stdin = tty
162+ cmd.Stdout = tty
163+ cmd.Stderr = tty
164+
165+ term, err := unix.IoctlGetTermios(int(tty.Fd()), unix.TCGETS)
166+ if err != nil {
167+ t.Fatal(err)
168+ }
169+
170+ // disable local echo, the repl does this manually and we don't want to echo input twice.
171+ // Without this, the input shows up twice, once when we send it (before the proc starts)
172+ // and again later when the proc reads it from buffered input.
173+ term.Lflag &^= unix.ECHO
174+
175+ if err := unix.IoctlSetTermios(int(tty.Fd()), unix.TCSETS, term); err != nil {
176+ t.Fatal(err)
177+ }
178+
179+ if _, err := io.Copy(ppty, input); err != nil {
180+ panic(err)
181+ }
182+ go io.Copy(output, ppty)
183+
184+ var killLock sync.Mutex
185+ didKill := false
186+
187+ time.AfterFunc(time.Second, func() {
188+ killLock.Lock()
189+ defer killLock.Unlock()
190+ didKill = true
191+ cmd.Process.Kill()
192+ })
193+
194+ if err := cmd.Run(); err != nil {
195+
196+ // the cmd failed to start
197+ if cmd.ProcessState == nil {
198+ t.Fatal(err)
199+ }
200+
201+ exitCode := cmd.ProcessState.ExitCode()
202+
203+ killLock.Lock()
204+ killed := didKill
205+ killLock.Unlock()
206+
207+ // we didn't exit with 0 (ok) or 137 (killed with SIGKILL)
208+ if exitCode != 0 && !killed {
209+ t.Fatal(err)
210+ }
211+ }
212+
213+ // ignore carraige returns
214+ outStr := strings.ReplaceAll(output.String(), "\r", "")
215+ outStr = strings.TrimPrefix(outStr, info.prefix)
216+
217+ if outStr != tc.expected {
218+ t.Log("Received:", escape(outStr))
219+ t.Log("Expected:", escape(tc.expected))
220+
221+ t.Fail()
222+ }
223+}
224+
225+func testPreview(t *testing.T, info testInfo) {
226+ t.Parallel()
227+
228+ for _, tc := range [...]testCase{
229+ {
230+ name: "number",
231+ input: "3",
232+ // (prompt) `3`
233+ expected: "\x1b[1G\x1b[0J--> \x1b[5G3",
234+ },
235+ {
236+ name: "number + number",
237+ input: "5 + 10",
238+ // (prompt) `5 + 10`
239+ // (preview) `15`
240+ expected: unescapeExpectedOutput(`
241+\x1b[1G\x1b[0J--> \x1b[5G5 + 10
242+\x1b[90m15\x1b[39m\x1b[11G\x1b[1A
243+`),
244+ },
245+ {
246+ name: "module variable + number",
247+ input: "a + 3",
248+ // (prompt) `a + 3`
249+ // (preview) `8`
250+ expected: unescapeExpectedOutput(`
251+\x1b[1G\x1b[0J--> \x1b[5Ga + 3
252+\x1b[90m8\x1b[39m\x1b[10G\x1b[1A
253+`),
254+ },
255+ {
256+ name: "non-existant variable",
257+ input: "notAVariable",
258+ // (prompt) `notAVariable`
259+ expected: "\x1b[1G\x1b[0J--> \x1b[5GnotAVariable",
260+ },
261+ {
262+ name: "repl-scoped variable",
263+ input: trimNewlines(`
264+const replScopedVariable = 'Bob'
265+replScopedVariable
266+`),
267+ // (prompt) `const replScopedVariable = 'Bob'`
268+ // (result) `undefined`
269+ // (prompt) `replScopedVariable`
270+ // (preview) `'Bob'`
271+ expected: unescapeExpectedOutput(`
272+\x1b[1G\x1b[0J--> \x1b[5Gconst replScopedVariable = 'Bob'
273+\x1b[90mundefined\x1b[39m
274+\x1b[1G\x1b[0J--> \x1b[5GreplScopedVariable
275+\x1b[90m'Bob'\x1b[39m\x1b[23G\x1b[1A
276+`),
277+ },
278+ {
279+ name: "global variable",
280+ input: "obj",
281+ // (prompt) `obj`
282+ // (preview) `{ abc: 'def' }`
283+ expected: unescapeExpectedOutput(`
284+\x1b[1G\x1b[0J--> \x1b[5Gobj
285+\x1b[90m{ abc: 'def' }\x1b[39m\x1b[8G\x1b[1A
286+`),
287+ },
288+ {
289+ name: "new *",
290+ input: "new MyClass()",
291+ // (prompt) `new MyClass()`
292+ // (preview) `MyClass {}`
293+ expected: unescapeExpectedOutput(`
294+\x1b[1G\x1b[0J--> \x1b[5Gnew MyClass()
295+\x1b[90mMyClass {}\x1b[39m\x1b[18G\x1b[1A
296+`),
297+ },
298+ {
299+ name: "global variable + module variable + repl variable",
300+ input: trimNewlines(`
301+const c = 23
302+a + b + c
303+`),
304+ // (prompt) `const c = 23;`
305+ // (result) `undefined`
306+ // (prompt) `a + b + c`
307+ // (preview) `60`
308+ expected: unescapeExpectedOutput(`
309+\x1b[1G\x1b[0J--> \x1b[5Gconst c = 23
310+\x1b[90mundefined\x1b[39m
311+\x1b[1G\x1b[0J--> \x1b[5Ga + b + c
312+\x1b[90m60\x1b[39m\x1b[14G\x1b[1A
313+`),
314+ },
315+ {
316+ name: "property of global object",
317+ input: "obj.abc",
318+ // (prompt) `obj.abc`
319+ // (preview) `'def'`
320+ expected: unescapeExpectedOutput(`
321+\x1b[1G\x1b[0J--> \x1b[5Gobj.abc
322+\x1b[90m'def'\x1b[39m\x1b[12G\x1b[1A
323+`),
324+ },
325+ {
326+ name: "assignment to global variable",
327+ input: "b = 3",
328+
329+ // (prompt) `b = 3`
330+ expected: "\x1b[1G\x1b[0J--> \x1b[5Gb = 3",
331+ },
332+ {
333+ name: "assignment to module variable",
334+ input: "obj = 23",
335+
336+ // (prompt) `obj = 23`
337+ expected: "\x1b[1G\x1b[0J--> \x1b[5Gobj = 23",
338+ },
339+ {
340+ name: "assignment to constant module variable",
341+ input: "obj = 23",
342+
343+ // (prompt) `obj = 23`
344+ expected: "\x1b[1G\x1b[0J--> \x1b[5Gobj = 23",
345+ },
346+ {
347+ name: "displays the cached value of a local variable",
348+ input: trimNewlines(`
349+setValue(2);
350+value
351+`),
352+ // (prompt) `setValue(2);`
353+ // (result) `undefined`
354+ // (prompt) `value`
355+ // (preview) `1`
356+ expected: unescapeExpectedOutput(`
357+\x1b[1G\x1b[0J--> \x1b[5GsetValue(2);
358+\x1b[90mundefined\x1b[39m
359+\x1b[1G\x1b[0J--> \x1b[5Gvalue
360+\x1b[90m1\x1b[39m\x1b[10G\x1b[1A
361+`),
362+ },
363+ } {
364+ t.Run(tc.name, func(t *testing.T) {
365+ tc.run(t, info)
366+ })
367+ }
368+}
369+
370+func testEvalResults(t *testing.T, info testInfo) {
371+ t.Parallel()
372+
373+ for _, tc := range [...]testCase{
374+ {
375+ name: "number",
376+ input: "3\n",
377+ // (prompt) `3`
378+ // (result) `3`
379+ // (prompt)
380+ expected: unescapeExpectedOutput(`
381+\x1b[1G\x1b[0J--> \x1b[5G3
382+\x1b[33m3\x1b[39m
383+\x1b[1G\x1b[0J--> \x1b[5G
384+`),
385+ },
386+ {
387+ name: "number + number",
388+ input: "5 + 10\n",
389+ // (prompt) `5 + 10`
390+ // (result) `15`
391+ // (prompt)
392+ expected: unescapeExpectedOutput(`
393+\x1b[1G\x1b[0J--> \x1b[5G5 + 10
394+\x1b[33m15\x1b[39m
395+\x1b[1G\x1b[0J--> \x1b[5G
396+`),
397+ },
398+ {
399+ name: "module variable + number",
400+ input: "a + 3\n",
401+ // (prompt) `a + 3`
402+ // (result) `8`
403+ // (prompt)
404+ expected: unescapeExpectedOutput(`
405+\x1b[1G\x1b[0J--> \x1b[5Ga + 3
406+\x1b[33m8\x1b[39m
407+\x1b[1G\x1b[0J--> \x1b[5G
408+`),
409+ },
410+ {
411+ name: "non-existant variable",
412+ input: "notAVariable\n",
413+ // (prompt) `notAVariable`
414+ // (result) (error)
415+ // (prompt)
416+ expected: unescapeExpectedOutput(`
417+\x1b[1G\x1b[0J--> \x1b[5GnotAVariable
418+\x1b[0m\x1b[31mReferenceError: notAVariable is not defined\x1b[0m
419+\x1b[1G\x1b[0J--> \x1b[5G
420+`),
421+ },
422+ {
423+ name: "repl-scoped variable",
424+ input: trimNewlines(`
425+const replScopedVariable = 'Bob'
426+replScopedVariable
427+
428+`),
429+ // (prompt) `const replScopedVariable = 'Bob'`
430+ // (result) `undefined`
431+ // (prompt) `replScopedVariable`
432+ // (result) `'Bob'`
433+ expected: unescapeExpectedOutput(`
434+\x1b[1G\x1b[0J--> \x1b[5Gconst replScopedVariable = 'Bob'
435+\x1b[90mundefined\x1b[39m
436+\x1b[1G\x1b[0J--> \x1b[5GreplScopedVariable
437+\x1b[32m'Bob'\x1b[39m
438+\x1b[1G\x1b[0J--> \x1b[5G
439+`),
440+ },
441+ {
442+ name: "global variable",
443+ input: "obj\n",
444+ // (prompt) `obj`
445+ // (result) `{ abc: 'def' }`
446+ expected: unescapeExpectedOutput(`
447+\x1b[1G\x1b[0J--> \x1b[5Gobj
448+{ abc: \x1b[32m'def'\x1b[39m }
449+\x1b[1G\x1b[0J--> \x1b[5G
450+`),
451+ },
452+ {
453+ name: "new *",
454+ input: "new MyClass()\n",
455+ // (prompt) `new MyClass()`
456+ // (result) `MyClass {}`
457+ expected: unescapeExpectedOutput(`
458+\x1b[1G\x1b[0J--> \x1b[5Gnew MyClass()
459+MyClass {}
460+\x1b[1G\x1b[0J--> \x1b[5G
461+`),
462+ },
463+ {
464+ name: "global variable + module variable + repl variable",
465+ input: trimNewlines(`
466+const c = 23
467+a + b + c
468+
469+`),
470+ // (prompt) `const c = 23;`
471+ // (result) `undefined`
472+ // (prompt) `a + b + c`
473+ // (result) `60`
474+ expected: unescapeExpectedOutput(`
475+\x1b[1G\x1b[0J--> \x1b[5Gconst c = 23
476+\x1b[90mundefined\x1b[39m
477+\x1b[1G\x1b[0J--> \x1b[5Ga + b + c
478+\x1b[33m60\x1b[39m
479+\x1b[1G\x1b[0J--> \x1b[5G
480+`),
481+ },
482+ {
483+ name: "property of global object",
484+ input: "obj.abc\n",
485+ // (prompt) `obj.abc`
486+ // (result) `'def'`
487+ // (prompt)
488+ expected: unescapeExpectedOutput(`
489+\x1b[1G\x1b[0J--> \x1b[5Gobj.abc
490+\x1b[32m'def'\x1b[39m
491+\x1b[1G\x1b[0J--> \x1b[5G
492+`),
493+ },
494+ {
495+ name: "assignment to a local variable",
496+ input: "b = 3\n",
497+ // (prompt) `b = 3`
498+ // (result) `3`
499+ // (prompt)
500+ expected: unescapeExpectedOutput(`
501+\x1b[1G\x1b[0J--> \x1b[5Gb = 3
502+\x1b[33m3\x1b[39m
503+\x1b[1G\x1b[0J--> \x1b[5G
504+`),
505+ },
506+ {
507+ name: "assignment to module variable",
508+ input: "obj = 23\n",
509+
510+ // (prompt) `obj = 23`
511+ // (result) `23`
512+ // (prompt)
513+ expected: unescapeExpectedOutput(`
514+\x1b[1G\x1b[0J--> \x1b[5Gobj = 23
515+\x1b[33m23\x1b[39m
516+\x1b[1G\x1b[0J--> \x1b[5G
517+`),
518+ },
519+ {
520+ name: "direct console.trace",
521+ input: "console.trace('Hello')\n",
522+
523+ // (prompt) `obj = 23`
524+ // (trace) `Trace: Hello`
525+ // (result) `23`
526+ // (prompt)
527+ expected: unescapeExpectedOutput(`
528+\x1b[1G\x1b[0J--> \x1b[5Gconsole.trace('Hello')
529+Trace: Hello
530+\x1b[90mundefined\x1b[39m
531+\x1b[1G\x1b[0J--> \x1b[5G
532+`),
533+ },
534+
535+ {
536+ name: "gets the latest value of a variable",
537+ input: trimNewlines(`
538+setValue(2);
539+value
540+
541+`),
542+ // (prompt) `setValue(2);`
543+ // (result) `undefined`
544+ // (prompt) `value`
545+ // (result) `2`
546+ // (prompt)
547+ expected: unescapeExpectedOutput(`
548+\x1b[1G\x1b[0J--> \x1b[5GsetValue(2);
549+\x1b[90mundefined\x1b[39m
550+\x1b[1G\x1b[0J--> \x1b[5Gvalue
551+\x1b[33m2\x1b[39m
552+\x1b[1G\x1b[0J--> \x1b[5G
553+`),
554+ },
555+ } {
556+ t.Run(tc.name, func(t *testing.T) {
557+ tc.run(t, info)
558+ })
559+
560+ }
561+}
562+
563+func TestREPL(t *testing.T) {
564+ const code = `
565+const str = 'Hello, World!';
566+const a = 5;
567+global.b = 32;
568+let obj = { abc: 'def' };
569+class MyClass {}
570+
571+let value = 1;
572+
573+function setValue(newValue) {
574+ value = 2;
575+}
576+`
577+
578+ for _, info := range [...]testInfo{
579+ {
580+ name: "string",
581+ args: getCodeArgs(true, true, code),
582+ },
583+ {
584+ name: "file",
585+ args: getFileArgs(t, true, true, code),
586+ prefix: "\x1b[0m\x1b[90mHint: hit control+c anytime to enter REPL.\x1b[0m\n",
587+ },
588+ } {
589+ for _, test := range [...]struct {
590+ run func(t *testing.T, info testInfo)
591+ name string
592+ }{
593+ {
594+ name: "result preview",
595+ run: testPreview,
596+ },
597+ {
598+ name: "line result",
599+ run: testEvalResults,
600+ },
601+ } {
602+ t.Run(test.name+"/"+info.name, func(t *testing.T) {
603+ test.run(t, info)
604+ })
605+ }
606+ }
607+
608+}