| 1 | // Package nodetest runs Node from a Go test, for the JavaScript that |
| 2 | // has a Go twin or a pure-logic core: a crypto envelope both languages |
| 3 | // must open, a date parser, a golden corpus. Node here is a dev |
| 4 | // toolchain, never a build dependency — application JS stays |
| 5 | // browser-only, and a browser test stays the engine of record for |
| 6 | // anything that needs a DOM. |
| 7 | // |
| 8 | // It exists because every package that did this carried its own copy |
| 9 | // of the same ten lines, and the copies had drifted in the ways that |
| 10 | // matter: some skipped with a message naming what went unverified and |
| 11 | // some did not, and none could be told that a missing node is a |
| 12 | // failure rather than a skip. A skip is the right answer on a laptop |
| 13 | // without Node and the wrong one on CI, where a runner image that lost |
| 14 | // node would retire every JavaScript check in the tree without anybody |
| 15 | // deciding to. RequireEnv is that switch, read in one place. |
| 16 | package nodetest |
| 17 | |
| 18 | import ( |
| 19 | "bytes" |
| 20 | "context" |
| 21 | "os" |
| 22 | "os/exec" |
| 23 | "strings" |
| 24 | "testing" |
| 25 | "time" |
| 26 | ) |
| 27 | |
| 28 | // RequireEnv names the environment variable that turns a missing node |
| 29 | // from a skip into a failure. Set it (to anything) wherever the suite |
| 30 | // must not quietly shrink — CI. |
| 31 | const RequireEnv = "FIESTA_TEST_REQUIRE_NODE" |
| 32 | |
| 33 | // DefaultTimeout bounds a run whose Cmd sets none. It is a backstop |
| 34 | // against a script that never exits (an unresolved promise, a stdin |
| 35 | // read nobody feeds), not a performance assertion: a hang fails the |
| 36 | // test with its output instead of holding the whole package until go |
| 37 | // test's own ten-minute alarm, which names no culprit. |
| 38 | const DefaultTimeout = 2 * time.Minute |
| 39 | |
| 40 | // Node returns the path of the node binary. With none on PATH it skips |
| 41 | // the test, naming the test so the skip says what went unverified — or |
| 42 | // fails it when RequireEnv is set. |
| 43 | func Node(t testing.TB) string { |
| 44 | t.Helper() |
| 45 | node, err := exec.LookPath("node") |
| 46 | if err != nil { |
| 47 | if os.Getenv(RequireEnv) != "" { |
| 48 | t.Fatalf("%s is set, so a Node test may not skip, but node is not on PATH: %v", RequireEnv, err) |
| 49 | } |
| 50 | t.Skipf("node is not on PATH, so %s's JavaScript is unverified here: install Node and rerun it", t.Name()) |
| 51 | } |
| 52 | return node |
| 53 | } |
| 54 | |
| 55 | // Cmd is one node invocation. |
| 56 | type Cmd struct { |
| 57 | // Args are node's arguments: its flags, the script, and the |
| 58 | // script's own arguments — {"--test", "js/golden.test.mjs"}, or |
| 59 | // {"--import", hook, "harness.mjs", "--mode"}. |
| 60 | Args []string |
| 61 | // Dir is the working directory. Empty means the test's own, which |
| 62 | // go test makes the package directory. |
| 63 | Dir string |
| 64 | // Stdin is fed to the script; nil is an empty stdin, never the |
| 65 | // test binary's own. |
| 66 | Stdin []byte |
| 67 | // Env is added to the test process's environment, so PATH and |
| 68 | // HOME survive and a caller only names what it changes (TZ=UTC). |
| 69 | Env []string |
| 70 | // Timeout overrides DefaultTimeout. |
| 71 | Timeout time.Duration |
| 72 | } |
| 73 | |
| 74 | // Run runs node (skipping as Node does when there is none) and returns |
| 75 | // its stdout. A non-zero exit or a timeout fails the test with BOTH |
| 76 | // streams: node --test reports its failures on stdout and a crashed |
| 77 | // script on stderr, so either alone hides half of what went wrong. |
| 78 | func Run(t testing.TB, c Cmd) []byte { |
| 79 | t.Helper() |
| 80 | node := Node(t) |
| 81 | timeout := c.Timeout |
| 82 | if timeout <= 0 { |
| 83 | timeout = DefaultTimeout |
| 84 | } |
| 85 | ctx, cancel := context.WithTimeout(context.Background(), timeout) |
| 86 | defer cancel() |
| 87 | |
| 88 | cmd := exec.CommandContext(ctx, node, c.Args...) |
| 89 | // The timeout kills node, but a child it spawned can still hold |
| 90 | // the output pipes open and Wait would block on them; WaitDelay |
| 91 | // bounds that too, so the backstop is a backstop. |
| 92 | cmd.WaitDelay = 5 * time.Second |
| 93 | cmd.Dir = c.Dir |
| 94 | cmd.Stdin = bytes.NewReader(c.Stdin) |
| 95 | if len(c.Env) > 0 { |
| 96 | cmd.Env = append(os.Environ(), c.Env...) |
| 97 | } |
| 98 | var stdout, stderr bytes.Buffer |
| 99 | cmd.Stdout, cmd.Stderr = &stdout, &stderr |
| 100 | if err := cmd.Run(); err != nil { |
| 101 | what := "node " + strings.Join(c.Args, " ") |
| 102 | if ctx.Err() == context.DeadlineExceeded { |
| 103 | t.Fatalf("%s: timed out after %s\nstdout:\n%s\nstderr:\n%s", what, timeout, stdout.String(), stderr.String()) |
| 104 | } |
| 105 | t.Fatalf("%s: %v\nstdout:\n%s\nstderr:\n%s", what, err, stdout.String(), stderr.String()) |
| 106 | } |
| 107 | return stdout.Bytes() |
| 108 | } |
| 109 | |