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

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