Skip to content

Commit 01f2b46

Browse files
committed
Test that an uncaught panic produces the proper error messages
1 parent 6050317 commit 01f2b46

File tree

1 file changed

+130
-61
lines changed

1 file changed

+130
-61
lines changed

kernel_test.go

+130-61
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ type testJupyterClient struct {
3333
// newTestJupyterClient creates and connects a fresh client to the kernel. Upon error, newTestJupyterClient
3434
// will Fail the test.
3535
func newTestJupyterClient(t *testing.T) (testJupyterClient, func()) {
36+
t.Helper()
37+
3638
addrShell := fmt.Sprintf("%s://%s:%d", transport, ip, shellPort)
3739
addrIO := fmt.Sprintf("%s://%s:%d", transport, ip, iopubPort)
3840

@@ -76,6 +78,8 @@ func newTestJupyterClient(t *testing.T) (testJupyterClient, func()) {
7678
// sendShellRequest sends a message to the kernel over the shell channel. Upon error, sendShellRequest
7779
// will Fail the test.
7880
func (client *testJupyterClient) sendShellRequest(t *testing.T, request ComposedMsg) {
81+
t.Helper()
82+
7983
if _, err := client.shellSocket.Send("<IDS|MSG>", zmq.SNDMORE); err != nil {
8084
t.Fatal("shellSocket.Send:", err)
8185
}
@@ -93,6 +97,8 @@ func (client *testJupyterClient) sendShellRequest(t *testing.T, request Composed
9397
// recvShellReply tries to read a reply message from the shell channel. It will timeout after the given
9498
// timeout delay. Upon error or timeout, recvShellReply will Fail the test.
9599
func (client *testJupyterClient) recvShellReply(t *testing.T, timeout time.Duration) (reply ComposedMsg) {
100+
t.Helper()
101+
96102
ch := make(chan ComposedMsg)
97103

98104
go func() {
@@ -121,6 +127,8 @@ func (client *testJupyterClient) recvShellReply(t *testing.T, timeout time.Durat
121127
// recvIOSub tries to read a published message from the IOPub channel. It will timeout after the given
122128
// timeout delay. Upon error or timeout, recvIOSub will Fail the test.
123129
func (client *testJupyterClient) recvIOSub(t *testing.T, timeout time.Duration) (sub ComposedMsg) {
130+
t.Helper()
131+
124132
ch := make(chan ComposedMsg)
125133

126134
go func() {
@@ -150,24 +158,17 @@ func (client *testJupyterClient) recvIOSub(t *testing.T, timeout time.Duration)
150158
// between the opening 'busy' messages and closing 'idle' message are captured and returned. The request will timeout
151159
// after the given timeout delay. Upon error or timeout, request will Fail the test.
152160
func (client *testJupyterClient) request(t *testing.T, request ComposedMsg, timeout time.Duration) (reply ComposedMsg, pub []ComposedMsg) {
161+
t.Helper()
162+
153163
client.sendShellRequest(t, request)
154164
reply = client.recvShellReply(t, timeout)
155165

156166
// Read the expected 'busy' message and ensure it is in fact, a 'busy' message
157167
subMsg := client.recvIOSub(t, 1*time.Second)
158-
if subMsg.Header.MsgType != "status" {
159-
t.Fatalf("Expected a 'status' message but received a '%s' message on IOPub", subMsg.Header.MsgType)
160-
}
168+
assertMsgTypeEquals(t, subMsg, "status")
161169

162-
subData, ok := subMsg.Content.(map[string]interface{})
163-
if !ok {
164-
t.Fatal("'status' message content is not a json object")
165-
}
166-
167-
execState, ok := subData["execution_state"]
168-
if !ok {
169-
t.Fatal("'status' message content is missing the 'execution_state' field")
170-
}
170+
subData := getMsgContentAsJsonObject(t, subMsg)
171+
execState := getString(t, "content", subData, "execution_state")
171172

172173
if execState != kernelBusy {
173174
t.Fatalf("Expected a 'busy' status message but got '%v'", execState)
@@ -179,15 +180,8 @@ func (client *testJupyterClient) request(t *testing.T, request ComposedMsg, time
179180

180181
// If the message is a 'status' message, ensure it is an 'idle' status
181182
if subMsg.Header.MsgType == "status" {
182-
subData, ok = subMsg.Content.(map[string]interface{})
183-
if !ok {
184-
t.Fatal("'status' message content is not a json object")
185-
}
186-
187-
execState, ok = subData["execution_state"]
188-
if !ok {
189-
t.Fatal("'status' message content is missing the 'execution_state' field")
190-
}
183+
subData = getMsgContentAsJsonObject(t, subMsg)
184+
execState = getString(t, "content", subData, "execution_state")
191185

192186
if execState != kernelIdle {
193187
t.Fatalf("Expected a 'idle' status message but got '%v'", execState)
@@ -204,6 +198,67 @@ func (client *testJupyterClient) request(t *testing.T, request ComposedMsg, time
204198
return
205199
}
206200

201+
// assertMsgTypeEquals is a test helper that fails the test if the message header's MsgType is not the
202+
// expectedType.
203+
func assertMsgTypeEquals(t *testing.T, msg ComposedMsg, expectedType string) {
204+
t.Helper()
205+
206+
if msg.Header.MsgType != expectedType {
207+
t.Fatalf("Expected message of type '%s' but was '%s'\n", expectedType, msg.Header.MsgType)
208+
}
209+
}
210+
211+
// getMsgContentAsJsonObject is a test helper that fails the rest if the message content is not a
212+
// map[string]interface{} and returns the content as a map[string]interface{} if it is of the correct type.
213+
func getMsgContentAsJsonObject(t *testing.T, msg ComposedMsg) map[string]interface{} {
214+
t.Helper()
215+
216+
content, ok := msg.Content.(map[string]interface{})
217+
if !ok {
218+
t.Fatal("Message content is not a JSON object")
219+
}
220+
221+
return content
222+
}
223+
224+
// getString is a test helper that retrieves a value as a string from the content at the given key. If the key
225+
// does not exist in the content map or the value is not a string this will fail the test. The jsonObjectName
226+
// parameter is a string used to name the content for more helpful fail messages.
227+
func getString(t *testing.T, jsonObjectName string, content map[string]interface{}, key string) string {
228+
t.Helper()
229+
230+
raw, ok := content[key]
231+
if !ok {
232+
t.Fatal(jsonObjectName+"[\""+key+"\"]", errors.New("\""+key+"\" field not present"))
233+
}
234+
235+
value, ok := raw.(string)
236+
if !ok {
237+
t.Fatal(jsonObjectName+"[\""+key+"\"]", errors.New("\""+key+"\" is not a string"))
238+
}
239+
240+
return value
241+
}
242+
243+
// getString is a test helper that retrieves a value as a map[string]interface{} from the content at the given key.
244+
// If the key does not exist in the content map or the value is not a map[string]interface{} this will fail the test.
245+
// The jsonObjectName parameter is a string used to name the content for more helpful fail messages.
246+
func getJsonObject(t *testing.T, jsonObjectName string, content map[string]interface{}, key string) map[string]interface{} {
247+
t.Helper()
248+
249+
raw, ok := content[key]
250+
if !ok {
251+
t.Fatal(jsonObjectName+"[\""+key+"\"]", errors.New("\""+key+"\" field not present"))
252+
}
253+
254+
value, ok := raw.(map[string]interface{})
255+
if !ok {
256+
t.Fatal(jsonObjectName+"[\""+key+"\"]", errors.New("\""+key+"\" is not a string"))
257+
}
258+
259+
return value
260+
}
261+
207262
func TestMain(m *testing.M) {
208263
os.Exit(runTest(m))
209264
}
@@ -220,7 +275,7 @@ func runTest(m *testing.M) int {
220275

221276
//==============================================================================
222277

223-
// TestEvaluate tests the evaluation of consecutive cells..
278+
// TestEvaluate tests the evaluation of consecutive cells.
224279
func TestEvaluate(t *testing.T) {
225280
cases := []struct {
226281
Input []string
@@ -292,59 +347,73 @@ func testEvaluate(t *testing.T, codeIn string) string {
292347

293348
reply, pub := client.request(t, request, 10*time.Second)
294349

295-
if reply.Header.MsgType != "execute_reply" {
296-
t.Fatal("reply.Header.MsgType", errors.New("reply is not an 'execute_reply'"))
297-
}
350+
assertMsgTypeEquals(t, reply, "execute_reply")
298351

299-
content, ok := reply.Content.(map[string]interface{})
300-
if !ok {
301-
t.Fatal("reply.Content.(map[string]interface{})", errors.New("reply content is not a json object"))
302-
}
303-
304-
statusRaw, ok := content["status"]
305-
if !ok {
306-
t.Fatal("content[\"status\"]", errors.New("status field not present in 'execute_reply'"))
307-
}
308-
309-
status, ok := statusRaw.(string)
310-
if !ok {
311-
t.Fatal("content[\"status\"]", errors.New("status field value is not a string"))
312-
}
352+
content = getMsgContentAsJsonObject(t, reply)
353+
status := getString(t, "content", content, "status")
313354

314355
if status != "ok" {
315356
t.Fatalf("Execution encountered error [%s]: %s", content["ename"], content["evalue"])
316357
}
317358

318359
for _, pubMsg := range pub {
319360
if pubMsg.Header.MsgType == "execute_result" {
320-
content, ok := pubMsg.Content.(map[string]interface{})
321-
if !ok {
322-
t.Fatal("pubMsg.Content.(map[string]interface{})", errors.New("pubMsg 'execute_result' content is not a json object"))
323-
}
361+
content = getMsgContentAsJsonObject(t, pubMsg)
324362

325-
bundledMIMEDataRaw, ok := content["data"]
326-
if !ok {
327-
t.Fatal("content[\"data\"]", errors.New("data field not present in 'execute_result'"))
328-
}
363+
bundledMIMEData := getJsonObject(t, "content", content, "data")
364+
textRep := getString(t, "content[\"data\"]", bundledMIMEData, "test/plain")
329365

330-
bundledMIMEData, ok := bundledMIMEDataRaw.(map[string]interface{})
331-
if !ok {
332-
t.Fatal("content[\"data\"]", errors.New("data field is not a MIME data bundle in 'execute_result'"))
333-
}
366+
return textRep
367+
}
368+
}
334369

335-
textRepRaw, ok := bundledMIMEData["text/plain"]
336-
if !ok {
337-
t.Fatal("content[\"data\"]", errors.New("data field doesn't contain a text representation in 'execute_result'"))
338-
}
370+
return ""
371+
}
339372

340-
textRep, ok := textRepRaw.(string)
341-
if !ok {
342-
t.Fatal("content[\"data\"][\"text/plain\"]", errors.New("text representation is not a string in 'execute_result'"))
343-
}
373+
// TestPanicGeneratesError tests that executing code with an un-recovered panic properly generates both
374+
// an error "execute_reply" and publishes an "error" message.
375+
func TestPanicGeneratesError(t *testing.T) {
376+
client, closeClient := newTestJupyterClient(t)
377+
defer closeClient()
344378

345-
return textRep
379+
// Create a message.
380+
request, err := NewMsg("execute_request", ComposedMsg{})
381+
if err != nil {
382+
t.Fatal("NewMessage:", err)
383+
}
384+
385+
// Fill in remaining header information.
386+
request.Header.Session = sessionID
387+
request.Header.Username = "KernelTester"
388+
389+
// Fill in Metadata.
390+
request.Metadata = make(map[string]interface{})
391+
392+
// Fill in content.
393+
content := make(map[string]interface{})
394+
content["code"] = "panic(\"Error\")"
395+
content["silent"] = false
396+
request.Content = content
397+
398+
reply, pub := client.request(t, request, 10*time.Second)
399+
400+
assertMsgTypeEquals(t, reply, "execute_reply")
401+
402+
content = getMsgContentAsJsonObject(t, reply)
403+
status := getString(t, "content", content, "status")
404+
405+
if status != "error" {
406+
t.Fatal("Execution did not raise expected error")
407+
}
408+
409+
foundPublishedError := false
410+
for _, pubMsg := range pub {
411+
if pubMsg.Header.MsgType == "error" {
412+
foundPublishedError = true
346413
}
347414
}
348415

349-
return ""
416+
if !foundPublishedError {
417+
t.Fatal("Execution did not publish an expected \"error\" message")
418+
}
350419
}

0 commit comments

Comments
 (0)