OILS / test / sh_spec.py View on Github | oilshell.org

1432 lines, 874 significant
1#!/usr/bin/env python2
2from __future__ import print_function
3"""
4sh_spec.py -- Test framework to compare shells.
5
6Assertion help:
7 stdout: A single line of expected stdout. Newline is implicit.
8 stdout-json: JSON-encoded string. Use for the empty string (no newline),
9 for unicode chars, etc.
10
11 stderr: Ditto for stderr stream.
12 status: Expected shell return code. If not specified, the case must exit 0.
13
14Results:
15 PASS - we got the ideal, expected value
16 OK - we got a value that was not ideal, but expected
17 For OSH this is behavior that was defined to be different?
18 N-I - Not implemented (e.g. $''). Assertions still checked (in case it
19 starts working)
20 BUG - we verified the value of a known bug
21 FAIL - we got an unexpected value. If the implementation can't be changed,
22 it should be converted to BUG or OK. Otherwise it should be made to
23 PASS.
24
25NOTE: The difference between OK and BUG is a matter of judgement. If the ideal
26behavior is a compile time error (code 2), a runtime error is generally OK.
27
28If ALL shells agree on a broken behavior, they are all marked OK (but our
29implementation will be PASS.) But if the behavior is NOT POSIX compliant, then
30it will be a BUG.
31
32If one shell disagrees with others, that is generally a BUG.
33
34Example test case:
35
36#### hello and fail
37echo hello
38echo world
39exit 1
40## status: 1
41#
42# ignored comment
43#
44## STDOUT:
45hello
46world
47## END
48
49"""
50
51import collections
52import cgi
53import cStringIO
54import errno
55import json
56import optparse
57import os
58import pprint
59import re
60import shutil
61import subprocess
62import sys
63
64from test import spec_lib
65from doctools import html_head
66
67log = spec_lib.log
68
69
70# Magic strings for other variants of OSH.
71
72# NOTE: osh_ALT is usually _bin/osh -- the release binary.
73# It would be better to rename these osh-cpython and osh-ovm. Have the concept
74# of a suffix?
75
76OSH_CPYTHON = ('osh', 'osh-dbg')
77OTHER_OSH = ('osh_ALT',)
78
79YSH_CPYTHON = ('ysh', 'ysh-dbg')
80OTHER_YSH = ('oil_ALT',)
81
82# For now, only count the Oils CPython failures. TODO: the spec-cpp job should
83# assert the osh-cpp and ysh-cpp deltas.
84OTHER_OILS = OTHER_OSH + OTHER_YSH + ('osh-cpp', 'ysh-cpp')
85
86
87class ParseError(Exception):
88 pass
89
90
91# EXAMPLES:
92## stdout: foo
93## stdout-json: ""
94#
95# In other words, it could be (name, value) or (qualifier, name, value)
96
97KEY_VALUE_RE = re.compile(r'''
98 [#][#] \s+
99 # optional prefix with qualifier and shells
100 (?: (OK|BUG|N-I) \s+ ([\w+/]+) \s+ )?
101 ([\w\-]+) # key
102 :
103 \s* (.*) # value
104''', re.VERBOSE)
105
106END_MULTILINE_RE = re.compile(r'''
107 [#][#] \s+ END
108''', re.VERBOSE)
109
110# Line types
111TEST_CASE_BEGIN = 0 # Starts with ####
112KEY_VALUE = 1 # Metadata
113KEY_VALUE_MULTILINE = 2 # STDOUT STDERR
114END_MULTILINE = 3 # STDOUT STDERR
115PLAIN_LINE = 4 # Uncommented
116EOF = 5
117
118LEX_OUTER = 0 # Ignore blank lines, e.g. for separating cases
119LEX_RAW = 1 # Blank lines are significant
120
121
122class Tokenizer(object):
123 """Modal lexer!"""
124
125 def __init__(self, f):
126 self.f = f
127
128 self.cursor = None
129 self.line_num = 0
130
131 self.next()
132
133 def _ClassifyLine(self, line, lex_mode):
134 if not line: # empty
135 return self.line_num, EOF, ''
136
137 if lex_mode == LEX_OUTER and not line.strip():
138 return None
139
140 if line.startswith('####'):
141 desc = line[4:].strip()
142 return self.line_num, TEST_CASE_BEGIN, desc
143
144 m = KEY_VALUE_RE.match(line)
145 if m:
146 qualifier, shells, name, value = m.groups()
147 # HACK: Expected data should have the newline.
148 if name in ('stdout', 'stderr'):
149 value += '\n'
150
151 if name in ('STDOUT', 'STDERR'):
152 token_type = KEY_VALUE_MULTILINE
153 else:
154 token_type = KEY_VALUE
155 return self.line_num, token_type, (qualifier, shells, name, value)
156
157 m = END_MULTILINE_RE.match(line)
158 if m:
159 return self.line_num, END_MULTILINE, None
160
161 # If it starts with ##, it should be metadata. This finds some typos.
162 if line.lstrip().startswith('##'):
163 raise RuntimeError('Invalid ## line %r' % line)
164
165 if line.lstrip().startswith('#'): # Ignore comments
166 return None # try again
167
168 # Non-empty line that doesn't start with '#'
169 # NOTE: We need the original line to test the whitespace sensitive <<-.
170 # And we need rstrip because we add newlines back below.
171 return self.line_num, PLAIN_LINE, line
172
173 def next(self, lex_mode=LEX_OUTER):
174 """Raises StopIteration when exhausted."""
175 while True:
176 line = self.f.readline()
177 self.line_num += 1
178
179 tok = self._ClassifyLine(line, lex_mode)
180 if tok is not None:
181 break
182
183 self.cursor = tok
184 return self.cursor
185
186 def peek(self):
187 return self.cursor
188
189
190def AddMetadataToCase(case, qualifier, shells, name, value):
191 shells = shells.split('/') # bash/dash/mksh
192 for shell in shells:
193 if shell not in case:
194 case[shell] = {}
195 case[shell][name] = value
196 case[shell]['qualifier'] = qualifier
197
198
199# Format of a test script.
200#
201# -- Code is either literal lines, or a commented out code: value.
202# code = PLAIN_LINE*
203# | '## code:' VALUE
204#
205# -- Key value pairs can be single- or multi-line
206# key_value = '##' KEY ':' VALUE
207# | KEY_VALUE_MULTILINE PLAIN_LINE* END_MULTILINE
208#
209# -- Description, then key-value pairs surrounding code.
210# test_case = '####' DESC
211# key_value*
212# code
213# key_value*
214#
215# -- Should be a blank line after each test case. Leading comments and code
216# -- are OK.
217#
218# test_file =
219# key_value* -- file level metadata
220# (test_case '\n')*
221
222
223def ParseKeyValue(tokens, case):
224 """Parse commented-out metadata in a test case.
225
226 The metadata must be contiguous.
227
228 Args:
229 tokens: Tokenizer
230 case: dictionary to add to
231 """
232 while True:
233 line_num, kind, item = tokens.peek()
234
235 if kind == KEY_VALUE_MULTILINE:
236 qualifier, shells, name, empty_value = item
237 if empty_value:
238 raise ParseError(
239 'Line %d: got value %r for %r, but the value should be on the '
240 'following lines' % (line_num, empty_value, name))
241
242 value_lines = []
243 while True:
244 tokens.next(lex_mode=LEX_RAW) # empty lines aren't skipped
245 _, kind2, item2 = tokens.peek()
246 if kind2 != PLAIN_LINE:
247 break
248 value_lines.append(item2)
249
250 value = ''.join(value_lines)
251
252 name = name.lower() # STDOUT -> stdout
253 if qualifier:
254 AddMetadataToCase(case, qualifier, shells, name, value)
255 else:
256 case[name] = value
257
258 # END token is optional.
259 if kind2 == END_MULTILINE:
260 tokens.next()
261
262 elif kind == KEY_VALUE:
263 qualifier, shells, name, value = item
264
265 if qualifier:
266 AddMetadataToCase(case, qualifier, shells, name, value)
267 else:
268 case[name] = value
269
270 tokens.next()
271
272 else: # Unknown token type
273 break
274
275
276def ParseCodeLines(tokens, case):
277 """Parse uncommented code in a test case."""
278 _, kind, item = tokens.peek()
279 if kind != PLAIN_LINE:
280 raise ParseError('Expected a line of code (got %r, %r)' % (kind, item))
281 code_lines = []
282 while True:
283 _, kind, item = tokens.peek()
284 if kind != PLAIN_LINE:
285 case['code'] = ''.join(code_lines)
286 return
287 code_lines.append(item)
288 tokens.next(lex_mode=LEX_RAW)
289
290
291def ParseTestCase(tokens):
292 """Parse a single test case and return it.
293
294 If at EOF, return None.
295 """
296 line_num, kind, item = tokens.peek()
297 if kind == EOF:
298 return None
299
300 if kind != TEST_CASE_BEGIN:
301 raise RuntimeError(
302 "line %d: Expected TEST_CASE_BEGIN, got %r" % (line_num, [kind, item]))
303
304 tokens.next()
305
306 case = {'desc': item, 'line_num': line_num}
307
308 ParseKeyValue(tokens, case)
309
310 # For broken code
311 if 'code' in case: # Got it through a key value pair
312 return case
313
314 ParseCodeLines(tokens, case)
315 ParseKeyValue(tokens, case)
316
317 return case
318
319
320_META_FIELDS = [
321 'our_shell',
322 'compare_shells',
323 'suite',
324 'tags',
325 'oils_failures_allowed',
326 ]
327
328
329def ParseTestFile(test_file, tokens):
330 """
331 test_file: Only for error message
332 """
333 file_metadata = {}
334 test_cases = []
335
336 try:
337 # Skip over the header. Setup code can go here, although would we have to
338 # execute it on every case?
339 while True:
340 line_num, kind, item = tokens.peek()
341 if kind != KEY_VALUE:
342 break
343
344 qualifier, shells, name, value = item
345 if qualifier is not None:
346 raise RuntimeError('Invalid qualifier in spec file metadata')
347 if shells is not None:
348 raise RuntimeError('Invalid shells in spec file metadata')
349
350 file_metadata[name] = value
351
352 tokens.next()
353
354 while True: # Loop over cases
355 test_case = ParseTestCase(tokens)
356 if test_case is None:
357 break
358 test_cases.append(test_case)
359
360 except StopIteration:
361 raise RuntimeError('Unexpected EOF parsing test cases')
362
363 for name in file_metadata:
364 if name not in _META_FIELDS:
365 raise RuntimeError('Invalid file metadata %r in %r' % (name, test_file))
366
367 return file_metadata, test_cases
368
369
370def CreateStringAssertion(d, key, assertions, qualifier=False):
371 found = False
372
373 exp = d.get(key)
374 if exp is not None:
375 a = EqualAssertion(key, exp, qualifier=qualifier)
376 assertions.append(a)
377 found = True
378
379 exp_json = d.get(key + '-json')
380 if exp_json is not None:
381 exp = json.loads(exp_json, encoding='utf-8')
382 a = EqualAssertion(key, exp, qualifier=qualifier)
383 assertions.append(a)
384 found = True
385
386 # For testing invalid unicode
387 exp_repr = d.get(key + '-repr')
388 if exp_repr is not None:
389 exp = eval(exp_repr)
390 a = EqualAssertion(key, exp, qualifier=qualifier)
391 assertions.append(a)
392 found = True
393
394 return found
395
396
397def CreateIntAssertion(d, key, assertions, qualifier=False):
398 exp = d.get(key) # expected
399 if exp is not None:
400 # For now, turn it into int
401 a = EqualAssertion(key, int(exp), qualifier=qualifier)
402 assertions.append(a)
403 return True
404 return False
405
406
407def CreateAssertions(case, sh_label):
408 """
409 Given a raw test case and a shell label, create EqualAssertion instances to
410 run.
411 """
412 assertions = []
413
414 # Whether we found assertions
415 stdout = False
416 stderr = False
417 status = False
418
419 # So the assertion are exactly the same for osh and osh_ALT
420
421 if sh_label.startswith('osh'):
422 case_sh = 'osh'
423 elif sh_label.startswith('bash'):
424 case_sh = 'bash'
425 else:
426 case_sh = sh_label
427
428 if case_sh in case:
429 q = case[case_sh]['qualifier']
430 if CreateStringAssertion(case[case_sh], 'stdout', assertions, qualifier=q):
431 stdout = True
432 if CreateStringAssertion(case[case_sh], 'stderr', assertions, qualifier=q):
433 stderr = True
434 if CreateIntAssertion(case[case_sh], 'status', assertions, qualifier=q):
435 status = True
436
437 if not stdout:
438 CreateStringAssertion(case, 'stdout', assertions)
439 if not stderr:
440 CreateStringAssertion(case, 'stderr', assertions)
441 if not status:
442 if 'status' in case:
443 CreateIntAssertion(case, 'status', assertions)
444 else:
445 # If the user didn't specify a 'status' assertion, assert that the exit
446 # code is 0.
447 a = EqualAssertion('status', 0)
448 assertions.append(a)
449
450 no_traceback = SubstringAssertion('stderr', 'Traceback (most recent')
451 assertions.append(no_traceback)
452
453 #print 'SHELL', shell
454 #pprint.pprint(case)
455 #print(assertions)
456 return assertions
457
458
459class Result(object):
460 """Result of an stdout/stderr/status assertion or of a (case, shell) cell.
461
462 Order is important: the result of a cell is the minimum of the results of
463 each assertion.
464 """
465 TIMEOUT = 0 # ONLY a cell result, not an assertion result
466 FAIL = 1
467 BUG = 2
468 NI = 3
469 OK = 4
470 PASS = 5
471
472 length = 6 # for loops
473
474
475class EqualAssertion(object):
476 """Check that two values are equal."""
477
478 def __init__(self, key, expected, qualifier=None):
479 self.key = key
480 self.expected = expected # expected value
481 self.qualifier = qualifier # whether this was a special case?
482
483 def __repr__(self):
484 return '<EqualAssertion %s == %r>' % (self.key, self.expected)
485
486 def Check(self, shell, record):
487 actual = record[self.key]
488 if actual != self.expected:
489 if len(str(self.expected)) < 40:
490 msg = '[%s %s] Expected %r, got %r' % (shell, self.key, self.expected,
491 actual)
492 else:
493 msg = '''
494[%s %s]
495Expected %r
496Got %r
497''' % (shell, self.key, self.expected, actual)
498
499 # TODO: Make this better and add a flag for it.
500 if 0:
501 import difflib
502 for line in difflib.unified_diff(
503 self.expected, actual, fromfile='expected', tofile='actual'):
504 print(repr(line))
505
506 return Result.FAIL, msg
507 if self.qualifier == 'BUG': # equal, but known bad
508 return Result.BUG, ''
509 if self.qualifier == 'N-I': # equal, and known UNIMPLEMENTED
510 return Result.NI, ''
511 if self.qualifier == 'OK': # equal, but ok (not ideal)
512 return Result.OK, ''
513 return Result.PASS, '' # ideal behavior
514
515
516class SubstringAssertion(object):
517 """Check that a string like stderr doesn't have a substring."""
518
519 def __init__(self, key, substring):
520 self.key = key
521 self.substring = substring
522
523 def __repr__(self):
524 return '<SubstringAssertion %s == %r>' % (self.key, self.substring)
525
526 def Check(self, shell, record):
527 actual = record[self.key]
528 if self.substring in actual:
529 msg = '[%s %s] Found %r' % (shell, self.key, self.substring)
530 return Result.FAIL, msg
531 return Result.PASS, ''
532
533
534class Stats(object):
535 def __init__(self, num_cases, sh_labels):
536 self.counters = collections.defaultdict(int)
537 c = self.counters
538 c['num_cases'] = num_cases
539 c['oils_num_passed'] = 0
540 c['oils_num_failed'] = 0
541 # Number of osh_ALT results that differed from osh.
542 c['oils_ALT_delta'] = 0
543
544 self.by_shell = {}
545 for sh in sh_labels:
546 self.by_shell[sh] = collections.defaultdict(int)
547 self.nonzero_results = collections.defaultdict(int)
548
549 self.tsv_rows = []
550
551 def Inc(self, counter_name):
552 self.counters[counter_name] += 1
553
554 def Get(self, counter_name):
555 return self.counters[counter_name]
556
557 def Set(self, counter_name, val):
558 self.counters[counter_name] = val
559
560 def ReportCell(self, case_num, cell_result, sh_label):
561 self.tsv_rows.append((str(case_num), sh_label, TEXT_CELLS[cell_result]))
562
563 self.by_shell[sh_label][cell_result] += 1
564 self.nonzero_results[cell_result] += 1
565
566 c = self.counters
567 if cell_result == Result.TIMEOUT:
568 c['num_timeout'] += 1
569 elif cell_result == Result.FAIL:
570 # Special logic: don't count osh_ALT because its failures will be
571 # counted in the delta.
572 if sh_label not in OTHER_OILS:
573 c['num_failed'] += 1
574
575 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
576 c['oils_num_failed'] += 1
577 elif cell_result == Result.BUG:
578 c['num_bug'] += 1
579 elif cell_result == Result.NI:
580 c['num_ni'] += 1
581 elif cell_result == Result.OK:
582 c['num_ok'] += 1
583 elif cell_result == Result.PASS:
584 c['num_passed'] += 1
585 if sh_label in OSH_CPYTHON + YSH_CPYTHON:
586 c['oils_num_passed'] += 1
587 else:
588 raise AssertionError()
589
590 def WriteTsv(self, f):
591 f.write('case\tshell\tresult\n')
592 for row in self.tsv_rows:
593 f.write('\t'.join(row))
594 f.write('\n')
595
596
597PIPE = subprocess.PIPE
598
599def RunCases(cases, case_predicate, shells, env, out, opts):
600 """
601 Run a list of test 'cases' for all 'shells' and write output to 'out'.
602 """
603 if opts.trace:
604 for _, sh in shells:
605 log('\tshell: %s', sh)
606 print('\twhich $SH: ', end='', file=sys.stderr)
607 subprocess.call(['which', sh])
608
609 #pprint.pprint(cases)
610
611 sh_labels = [sh_label for sh_label, _ in shells]
612
613 out.WriteHeader(sh_labels)
614 stats = Stats(len(cases), sh_labels)
615
616 # Make an environment for each shell. $SH is the path to the shell, so we
617 # can test flags, etc.
618 sh_env = []
619 for _, sh_path in shells:
620 e = dict(env)
621 e[opts.sh_env_var_name] = sh_path
622 sh_env.append(e)
623
624 # Determine which one (if any) is osh-cpython, for comparison against other
625 # shells.
626 osh_cpython_index = -1
627 for i, (sh_label, _) in enumerate(shells):
628 if sh_label in OSH_CPYTHON:
629 osh_cpython_index = i
630 break
631
632 timeout_dir = os.path.abspath('_tmp/spec/timeouts')
633 try:
634 shutil.rmtree(timeout_dir)
635 os.mkdir(timeout_dir)
636 except OSError:
637 pass
638
639 # Now run each case, and print a table.
640 for i, case in enumerate(cases):
641 line_num = case['line_num']
642 desc = case['desc']
643 code = case['code']
644
645 if opts.trace:
646 log('case %d: %s', i, desc)
647
648 if not case_predicate(i, case):
649 stats.Inc('num_skipped')
650 continue
651
652 if opts.do_print:
653 print('#### %s' % case['desc'])
654 print(case['code'])
655 print()
656 continue
657
658 stats.Inc('num_cases_run')
659
660 result_row = []
661
662 for shell_index, (sh_label, sh_path) in enumerate(shells):
663 timeout_file = os.path.join(timeout_dir, '%02d-%s' % (i, sh_label))
664 if opts.timeout:
665 if opts.timeout_bin:
666 # This is what smoosh itself uses. See smoosh/tests/shell_tests.sh
667 # QUIRK: interval can only be a whole number
668 argv = [
669 opts.timeout_bin,
670 '-t', opts.timeout,
671 # Somehow I'm not able to get this timeout file working? I think
672 # it has a bug when using stdin. It waits for the background
673 # process too.
674
675 #'-i', '1',
676 #'-l', timeout_file
677 ]
678 else:
679 # This kills hanging tests properly, but somehow they fail with code
680 # -9?
681 #argv = ['timeout', '-s', 'KILL', opts.timeout]
682
683 # s suffix for seconds
684 argv = ['timeout', opts.timeout + 's']
685 else:
686 argv = []
687 argv.append(sh_path)
688
689 # dash doesn't support -o posix
690 if opts.posix and sh_label != 'dash':
691 argv.extend(['-o', 'posix'])
692
693 if opts.trace:
694 log('\targv: %s', ' '.join(argv))
695
696 case_env = sh_env[shell_index]
697
698 # Unique dir for every test case and shell
699 tmp_base = os.path.normpath(opts.tmp_env) # no . or ..
700 case_tmp_dir = os.path.join(tmp_base, '%02d-%s' % (i, sh_label))
701
702 try:
703 os.makedirs(case_tmp_dir)
704 except OSError as e:
705 if e.errno != errno.EEXIST:
706 raise
707
708 # Some tests assume _tmp exists
709 # TODO: get rid of this in the common case, to save inodes! I guess have
710 # an opt-in setting per FILE, like make_underscore_tmp: true.
711 try:
712 os.mkdir(os.path.join(case_tmp_dir, '_tmp'))
713 except OSError as e:
714 if e.errno != errno.EEXIST:
715 raise
716
717 case_env['TMP'] = case_tmp_dir
718
719 if opts.pyann_out_dir:
720 case_env = dict(case_env)
721 case_env['PYANN_OUT'] = os.path.join(opts.pyann_out_dir, '%d.json' % i)
722
723 try:
724 p = subprocess.Popen(argv, env=case_env, cwd=case_tmp_dir,
725 stdin=PIPE, stdout=PIPE, stderr=PIPE)
726 except OSError as e:
727 print('Error running %r: %s' % (sh_path, e), file=sys.stderr)
728 sys.exit(1)
729
730 p.stdin.write(code)
731 p.stdin.close()
732
733 actual = {}
734 actual['stdout'] = p.stdout.read()
735 actual['stderr'] = p.stderr.read()
736 p.stdout.close()
737 p.stderr.close()
738
739 actual['status'] = p.wait()
740
741 if opts.timeout_bin and os.path.exists(timeout_file):
742 cell_result = Result.TIMEOUT
743 elif not opts.timeout_bin and actual['status'] == 124:
744 cell_result = Result.TIMEOUT
745 else:
746 messages = []
747 cell_result = Result.PASS
748
749 # TODO: Warn about no assertions? Well it will always test the error
750 # code.
751 assertions = CreateAssertions(case, sh_label)
752 for a in assertions:
753 result, msg = a.Check(sh_label, actual)
754 # The minimum one wins.
755 # If any failed, then the result is FAIL.
756 # If any are OK, but none are FAIL, the result is OK.
757 cell_result = min(cell_result, result)
758 if msg:
759 messages.append(msg)
760
761 if cell_result != Result.PASS or opts.details:
762 d = (i, sh_label, actual['stdout'], actual['stderr'], messages)
763 out.AddDetails(d)
764
765 result_row.append(cell_result)
766
767 stats.ReportCell(i, cell_result, sh_label)
768
769 if sh_label in OTHER_OSH:
770 # This is only an error if we tried to run ANY OSH.
771 if osh_cpython_index == -1:
772 raise RuntimeError("Couldn't determine index of osh-cpython")
773
774 other_result = result_row[shell_index]
775 cpython_result = result_row[osh_cpython_index]
776 if other_result != cpython_result:
777 stats.Inc('oils_ALT_delta')
778
779 out.WriteRow(i, line_num, result_row, desc)
780
781 return stats
782
783
784# ANSI color constants
785_RESET = '\033[0;0m'
786_BOLD = '\033[1m'
787
788_RED = '\033[31m'
789_GREEN = '\033[32m'
790_YELLOW = '\033[33m'
791_PURPLE = '\033[35m'
792
793
794TEXT_CELLS = {
795 Result.TIMEOUT: 'TIME',
796 Result.FAIL: 'FAIL',
797 Result.BUG: 'BUG',
798 Result.NI: 'N-I',
799 Result.OK: 'ok',
800 Result.PASS: 'pass',
801}
802
803ANSI_COLORS = {
804 Result.TIMEOUT: _PURPLE,
805 Result.FAIL: _RED,
806 Result.BUG: _YELLOW,
807 Result.NI: _YELLOW,
808 Result.OK: _YELLOW,
809 Result.PASS: _GREEN,
810}
811
812def _AnsiCells():
813 lookup = {}
814 for i in xrange(Result.length):
815 lookup[i] = ''.join([ANSI_COLORS[i], _BOLD, TEXT_CELLS[i], _RESET])
816 return lookup
817
818ANSI_CELLS = _AnsiCells()
819
820
821HTML_CELLS = {
822 Result.TIMEOUT: '<td class="timeout">TIME',
823 Result.FAIL: '<td class="fail">FAIL',
824 Result.BUG: '<td class="bug">BUG',
825 Result.NI: '<td class="n-i">N-I',
826 Result.OK: '<td class="ok">ok',
827 Result.PASS: '<td class="pass">pass',
828}
829
830
831def _ValidUtf8String(s):
832 """Return an arbitrary string as a readable utf-8 string.
833
834 We output utf-8 to either HTML or the console. If we get invalid utf-8 as
835 stdout/stderr (which is very possible), then show the ASCII repr().
836 """
837 try:
838 s.decode('utf-8')
839 return s # it decoded OK
840 except UnicodeDecodeError:
841 return repr(s) # ASCII representation
842
843
844class Output(object):
845
846 def __init__(self, f, verbose):
847 self.f = f
848 self.verbose = verbose
849 self.details = []
850
851 def BeginCases(self, test_file):
852 pass
853
854 def WriteHeader(self, sh_labels):
855 pass
856
857 def WriteRow(self, i, line_num, row, desc):
858 pass
859
860 def EndCases(self, sh_labels, stats):
861 pass
862
863 def AddDetails(self, entry):
864 self.details.append(entry)
865
866 # Helper function
867 def _WriteDetailsAsText(self, details):
868 for case_index, shell, stdout, stderr, messages in details:
869 print('case: %d' % case_index, file=self.f)
870 for m in messages:
871 print(m, file=self.f)
872
873 # Assume the terminal can show utf-8, but we don't want random binary.
874 print('%s stdout:' % shell, file=self.f)
875 print(_ValidUtf8String(stdout), file=self.f)
876
877 print('%s stderr:' % shell, file=self.f)
878 print(_ValidUtf8String(stderr), file=self.f)
879
880 print('', file=self.f)
881
882
883class TeeOutput(object):
884 """For multiple outputs in one run, e.g. HTML and TSV.
885
886 UNUSED
887 """
888
889 def __init__(self, outs):
890 self.outs = outs
891
892 def BeginCases(self, test_file):
893 for out in self.outs:
894 out.BeginCases(test_file)
895
896 def WriteHeader(self, sh_labels):
897 for out in self.outs:
898 out.WriteHeader(sh_labels)
899
900 def WriteRow(self, i, line_num, row, desc):
901 for out in self.outs:
902 out.WriteRow(i, line_num, row, desc)
903
904 def EndCases(self, sh_labels, stats):
905 for out in self.outs:
906 out.EndCases(sh_labels, stats)
907
908 def AddDetails(self, entry):
909 for out in self.outs:
910 out.AddDetails(entry)
911
912
913class TsvOutput(Output):
914 """Write a plain-text TSV file.
915
916 UNUSED since we are outputting LONG format with --tsv-output.
917 """
918
919 def WriteHeader(self, sh_labels):
920 self.f.write('case\tline\t') # case number and line number
921 for sh_label in sh_labels:
922 self.f.write(sh_label)
923 self.f.write('\t')
924 self.f.write('\n')
925
926 def WriteRow(self, i, line_num, row, desc):
927 self.f.write('%3d\t%3d\t' % (i, line_num))
928
929 for result in row:
930 c = TEXT_CELLS[result]
931 self.f.write(c)
932 self.f.write('\t')
933
934 # note: 'desc' could use TSV8, but just ignore it for now
935 #self.f.write(desc)
936 self.f.write('\n')
937
938
939class AnsiOutput(Output):
940
941 def BeginCases(self, test_file):
942 self.f.write('%s\n' % test_file)
943
944 def WriteHeader(self, sh_labels):
945 self.f.write(_BOLD)
946 self.f.write('case\tline\t') # case number and line number
947 for sh_label in sh_labels:
948 self.f.write(sh_label)
949 self.f.write('\t')
950 self.f.write(_RESET)
951 self.f.write('\n')
952
953 def WriteRow(self, i, line_num, row, desc):
954 self.f.write('%3d\t%3d\t' % (i, line_num))
955
956 for result in row:
957 c = ANSI_CELLS[result]
958 self.f.write(c)
959 self.f.write('\t')
960
961 self.f.write(desc)
962 self.f.write('\n')
963
964 if self.verbose:
965 self._WriteDetailsAsText(self.details)
966 self.details = []
967
968 def _WriteShellSummary(self, sh_labels, stats):
969 if len(stats.nonzero_results) <= 1: # Skip trivial summaries
970 return
971
972 # Reiterate header
973 self.f.write(_BOLD)
974 self.f.write('\t\t')
975 for sh_label in sh_labels:
976 self.f.write(sh_label)
977 self.f.write('\t')
978 self.f.write(_RESET)
979 self.f.write('\n')
980
981 # Write totals by cell.
982 for result in sorted(stats.nonzero_results, reverse=True):
983 self.f.write('\t%s' % ANSI_CELLS[result])
984 for sh_label in sh_labels:
985 self.f.write('\t%d' % stats.by_shell[sh_label][result])
986 self.f.write('\n')
987
988 # The bottom row is all the same, but it helps readability.
989 self.f.write('\ttotal')
990 for sh_label in sh_labels:
991 self.f.write('\t%d' % stats.counters['num_cases_run'])
992 self.f.write('\n')
993
994 def EndCases(self, sh_labels, stats):
995 print()
996 self._WriteShellSummary(sh_labels, stats)
997
998
999class HtmlOutput(Output):
1000
1001 def __init__(self, f, verbose, spec_name, sh_labels, cases):
1002 Output.__init__(self, f, verbose)
1003 self.spec_name = spec_name
1004 self.sh_labels = sh_labels # saved from header
1005 self.cases = cases # for linking to code
1006 self.row_html = [] # buffered
1007
1008 def _SourceLink(self, line_num, desc):
1009 return '<a href="%s.test.html#L%d">%s</a>' % (
1010 self.spec_name, line_num, cgi.escape(desc))
1011
1012 def BeginCases(self, test_file):
1013 css_urls = [ '../../../web/base.css', '../../../web/spec-tests.css' ]
1014 title = '%s: spec test case results' % self.spec_name
1015 html_head.Write(self.f, title, css_urls=css_urls)
1016
1017 self.f.write('''\
1018 <body class="width60">
1019 <p id="home-link">
1020 <a href=".">spec test index</a>
1021 /
1022 <a href="/">oilshell.org</a>
1023 </p>
1024 <h1>Results for %s</h1>
1025 <table>
1026 ''' % test_file)
1027
1028 def _WriteShellSummary(self, sh_labels, stats):
1029 # NOTE: This table has multiple <thead>, which seems OK.
1030 self.f.write('''
1031<thead>
1032 <tr class="table-header">
1033 ''')
1034
1035 columns = ['status'] + sh_labels + ['']
1036 for c in columns:
1037 self.f.write('<td>%s</td>' % c)
1038
1039 self.f.write('''
1040 </tr>
1041</thead>
1042''')
1043
1044 # Write totals by cell.
1045 for result in sorted(stats.nonzero_results, reverse=True):
1046 self.f.write('<tr>')
1047
1048 self.f.write(HTML_CELLS[result])
1049 self.f.write('</td> ')
1050
1051 for sh_label in sh_labels:
1052 self.f.write('<td>%d</td>' % stats.by_shell[sh_label][result])
1053
1054 self.f.write('<td></td>')
1055 self.f.write('</tr>\n')
1056
1057 # The bottom row is all the same, but it helps readability.
1058 self.f.write('<tr>')
1059 self.f.write('<td>total</td>')
1060 for sh_label in sh_labels:
1061 self.f.write('<td>%d</td>' % stats.counters['num_cases_run'])
1062 self.f.write('<td></td>')
1063 self.f.write('</tr>\n')
1064
1065 # Blank row for space.
1066 self.f.write('<tr>')
1067 for i in xrange(len(sh_labels) + 2):
1068 self.f.write('<td style="height: 2em"></td>')
1069 self.f.write('</tr>\n')
1070
1071 def WriteHeader(self, sh_labels):
1072 f = cStringIO.StringIO()
1073
1074 f.write('''
1075<thead>
1076 <tr class="table-header">
1077 ''')
1078
1079 columns = ['case'] + sh_labels
1080 for c in columns:
1081 f.write('<td>%s</td>' % c)
1082 f.write('<td class="case-desc">description</td>')
1083
1084 f.write('''
1085 </tr>
1086</thead>
1087''')
1088
1089 self.row_html.append(f.getvalue())
1090
1091 def WriteRow(self, i, line_num, row, desc):
1092 f = cStringIO.StringIO()
1093 f.write('<tr>')
1094 f.write('<td>%3d</td>' % i)
1095
1096 show_details = False
1097
1098 for result in row:
1099 c = HTML_CELLS[result]
1100 if result not in (Result.PASS, Result.TIMEOUT): # nothing to show
1101 show_details = True
1102
1103 f.write(c)
1104 f.write('</td>')
1105 f.write('\t')
1106
1107 f.write('<td class="case-desc">')
1108 f.write(self._SourceLink(line_num, desc))
1109 f.write('</td>')
1110 f.write('</tr>\n')
1111
1112 # Show row with details link.
1113 if show_details:
1114 f.write('<tr>')
1115 f.write('<td class="details-row"></td>') # for the number
1116
1117 for col_index, result in enumerate(row):
1118 f.write('<td class="details-row">')
1119 if result != Result.PASS:
1120 sh_label = self.sh_labels[col_index]
1121 f.write('<a href="#details-%s-%s">details</a>' % (i, sh_label))
1122 f.write('</td>')
1123
1124 f.write('<td class="details-row"></td>') # for the description
1125 f.write('</tr>\n')
1126
1127 self.row_html.append(f.getvalue()) # buffer it
1128
1129 def _WriteStats(self, stats):
1130 self.f.write(
1131 '%(num_passed)d passed, %(num_ok)d OK, '
1132 '%(num_ni)d not implemented, %(num_bug)d BUG, '
1133 '%(num_failed)d failed, %(num_timeout)d timeouts, '
1134 '%(num_skipped)d cases skipped\n' % stats.counters)
1135
1136 def EndCases(self, sh_labels, stats):
1137 self._WriteShellSummary(sh_labels, stats)
1138
1139 # Write all the buffered rows
1140 for h in self.row_html:
1141 self.f.write(h)
1142
1143 self.f.write('</table>\n')
1144 self.f.write('<pre>')
1145 self._WriteStats(stats)
1146 if stats.Get('oils_num_failed'):
1147 self.f.write('%(oils_num_failed)d failed under osh\n' % stats.counters)
1148 self.f.write('</pre>')
1149
1150 if self.details:
1151 self._WriteDetails()
1152
1153 self.f.write('</body></html>')
1154
1155 def _WriteDetails(self):
1156 self.f.write("<h2>Details on runs that didn't PASS</h2>")
1157 self.f.write('<table id="details">')
1158
1159 for case_index, sh_label, stdout, stderr, messages in self.details:
1160 self.f.write('<tr>')
1161 self.f.write('<td><a name="details-%s-%s"></a><b>%s</b></td>' % (
1162 case_index, sh_label, sh_label))
1163
1164 self.f.write('<td>')
1165
1166 # Write description and link to the code
1167 case = self.cases[case_index]
1168 line_num = case['line_num']
1169 desc = case['desc']
1170 self.f.write('%d ' % case_index)
1171 self.f.write(self._SourceLink(line_num, desc))
1172 self.f.write('<br/><br/>\n')
1173
1174 for m in messages:
1175 self.f.write('<span class="assertion">%s</span><br/>\n' % cgi.escape(m))
1176 if messages:
1177 self.f.write('<br/>\n')
1178
1179 def _WriteRaw(s):
1180 self.f.write('<pre>')
1181
1182 # stdout might contain invalid utf-8; make it valid;
1183 valid_utf8 = _ValidUtf8String(s)
1184
1185 self.f.write(cgi.escape(valid_utf8))
1186 self.f.write('</pre>')
1187
1188 self.f.write('<i>stdout:</i> <br/>\n')
1189 _WriteRaw(stdout)
1190
1191 self.f.write('<i>stderr:</i> <br/>\n')
1192 _WriteRaw(stderr)
1193
1194 self.f.write('</td>')
1195 self.f.write('</tr>')
1196
1197 self.f.write('</table>')
1198
1199
1200def MakeTestEnv(opts):
1201 if not opts.tmp_env:
1202 raise RuntimeError('--tmp-env required')
1203 if not opts.path_env:
1204 raise RuntimeError('--path-env required')
1205 env = {
1206 'PATH': opts.path_env,
1207 #'LANG': opts.lang_env,
1208 }
1209 for p in opts.env_pair:
1210 name, value = p.split('=', 1)
1211 env[name] = value
1212
1213 return env
1214
1215
1216def _DefaultSuite(spec_name):
1217 if spec_name.startswith('ysh-'):
1218 suite = 'ysh'
1219 elif spec_name.startswith('hay'): # hay.test.sh is ysh
1220 suite = 'ysh'
1221
1222 elif spec_name.startswith('tea-'):
1223 suite = 'tea'
1224 else:
1225 suite = 'osh'
1226
1227 return suite
1228
1229
1230def ParseTestList(test_files):
1231 for test_file in test_files:
1232 with open(test_file) as f:
1233 tokens = Tokenizer(f)
1234 try:
1235 file_metadata, cases = ParseTestFile(test_file, tokens)
1236 except RuntimeError as e:
1237 log('ERROR in %r', test_file)
1238 raise
1239
1240 tmp = os.path.basename(test_file)
1241 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1242
1243 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1244
1245 tmp = file_metadata.get('tags')
1246 tags = tmp.split() if tmp else []
1247
1248 # Don't need compare_shells, etc. to decide what to run
1249
1250 row = {'spec_name': spec_name, 'suite': suite, 'tags': tags}
1251 #print(row)
1252 yield row
1253
1254
1255def main(argv):
1256 # First check if bash is polluting the environment. Tests rely on the
1257 # environment.
1258 v = os.getenv('RANDOM')
1259 if v is not None:
1260 raise AssertionError('got $RANDOM = %s' % v)
1261 v = os.getenv('PPID')
1262 if v is not None:
1263 raise AssertionError('got $PPID = %s' % v)
1264
1265 p = optparse.OptionParser('%s [options] TEST_FILE shell...' % sys.argv[0])
1266 spec_lib.DefineCommon(p)
1267 spec_lib.DefineShSpec(p)
1268 opts, argv = p.parse_args(argv)
1269
1270 # --print-tagged to figure out what to run
1271 if opts.print_tagged:
1272 to_find = opts.print_tagged
1273 for row in ParseTestList(argv[1:]):
1274 if to_find in row['tags']:
1275 print(row['spec_name'])
1276 return 0
1277
1278 # --print-table to figure out what to run
1279 if opts.print_table:
1280 for row in ParseTestList(argv[1:]):
1281 print('%(suite)s\t%(spec_name)s' % row)
1282 #print(row)
1283 return 0
1284
1285 #
1286 # Now deal with a single file
1287 #
1288
1289 try:
1290 test_file = argv[1]
1291 except IndexError:
1292 p.print_usage()
1293 return 1
1294
1295 with open(test_file) as f:
1296 tokens = Tokenizer(f)
1297 file_metadata, cases = ParseTestFile(test_file, tokens)
1298
1299 # List test cases and return
1300 if opts.do_list:
1301 for i, case in enumerate(cases):
1302 if opts.verbose: # print the raw dictionary for debugging
1303 print(pprint.pformat(case))
1304 else:
1305 print('%d\t%s' % (i, case['desc']))
1306 return 0
1307
1308 # for test/spec-cpp.sh
1309 if opts.print_spec_suite:
1310 tmp = os.path.basename(test_file)
1311 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1312
1313 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1314 print(suite)
1315 return 0
1316
1317 if opts.verbose:
1318 for k, v in file_metadata.items():
1319 print('\t%-20s: %s' % (k, v), file=sys.stderr)
1320 print('', file=sys.stderr)
1321
1322 if opts.oils_bin_dir:
1323
1324 shells = []
1325
1326 if opts.compare_shells:
1327 comp = file_metadata.get('compare_shells')
1328 # Compare 'compare_shells' and Python
1329 shells.extend(comp.split() if comp else [])
1330
1331 # Always run with the Python version
1332 our_shell = file_metadata.get('our_shell', 'osh') # default is OSH
1333 shells.append(os.path.join(opts.oils_bin_dir, our_shell))
1334
1335 # Legacy OVM/CPython build
1336 if opts.ovm_bin_dir:
1337 shells.append(os.path.join(opts.ovm_bin_dir, our_shell))
1338
1339 # New C++ build
1340 if opts.oils_cpp_bin_dir:
1341 shells.append(os.path.join(opts.oils_cpp_bin_dir, our_shell))
1342
1343 # Overwrite it when --oils-bin-dir is set
1344 # It's no longer a flag
1345 opts.oils_failures_allowed = \
1346 int(file_metadata.get('oils_failures_allowed', 0))
1347
1348 else:
1349 # TODO: remove this mode?
1350 shells = argv[2:]
1351
1352 shell_pairs = spec_lib.MakeShellPairs(shells)
1353
1354 if opts.range:
1355 begin, end = spec_lib.ParseRange(opts.range)
1356 case_predicate = spec_lib.RangePredicate(begin, end)
1357 elif opts.regex:
1358 desc_re = re.compile(opts.regex, re.IGNORECASE)
1359 case_predicate = spec_lib.RegexPredicate(desc_re)
1360 else:
1361 case_predicate = lambda i, case: True
1362
1363 out_f = sys.stderr if opts.do_print else sys.stdout
1364
1365 # Set up output style. Also see asdl/format.py
1366 if opts.format == 'ansi':
1367 out = AnsiOutput(out_f, opts.verbose)
1368
1369 elif opts.format == 'html':
1370 spec_name = os.path.basename(test_file)
1371 spec_name = spec_name.split('.')[0]
1372
1373 sh_labels = [label for label, _ in shell_pairs]
1374
1375 out = HtmlOutput(out_f, opts.verbose, spec_name, sh_labels, cases)
1376
1377 else:
1378 raise AssertionError()
1379
1380 out.BeginCases(os.path.basename(test_file))
1381
1382 env = MakeTestEnv(opts)
1383 stats = RunCases(cases, case_predicate, shell_pairs, env, out, opts)
1384
1385 out.EndCases([sh_label for sh_label, _ in shell_pairs], stats)
1386
1387 if opts.tsv_output:
1388 with open(opts.tsv_output, 'w') as f:
1389 stats.WriteTsv(f)
1390
1391 # TODO: Could --stats-{file,template} be a separate awk step on .tsv files?
1392 stats.Set('oils_failures_allowed', opts.oils_failures_allowed)
1393 if opts.stats_file:
1394 with open(opts.stats_file, 'w') as f:
1395 f.write(opts.stats_template % stats.counters)
1396 f.write('\n') # bash 'read' requires a newline
1397
1398 # spec/smoke.test.sh -> smoke
1399 test_name = os.path.basename(test_file).split('.')[0]
1400
1401 return _SuccessOrFailure(test_name, opts.oils_failures_allowed, stats)
1402
1403
1404def _SuccessOrFailure(test_name, allowed, stats):
1405 all_count = stats.Get('num_failed')
1406 oils_count = stats.Get('oils_num_failed')
1407
1408 # If we got EXACTLY the allowed number of failures, exit 0.
1409 if allowed == all_count and all_count == oils_count:
1410 log('%s: note: Got %d allowed oils failures (exit with code 0)',
1411 test_name, allowed)
1412 return 0
1413 else:
1414 log('')
1415 log('%s: FATAL: Got %d failures (%d oils failures), but %d are allowed',
1416 test_name, all_count, oils_count, allowed)
1417 log('')
1418
1419 return 1
1420
1421
1422if __name__ == '__main__':
1423 try:
1424 sys.exit(main(sys.argv))
1425 except KeyboardInterrupt as e:
1426 print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
1427 sys.exit(1)
1428 except RuntimeError as e:
1429 print('FATAL: %s' % e, file=sys.stderr)
1430 sys.exit(1)
1431
1432# vim: sw=2