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

1428 lines, 870 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
732 actual = {}
733 actual['stdout'], actual['stderr'] = p.communicate()
734
735 actual['status'] = p.wait()
736
737 if opts.timeout_bin and os.path.exists(timeout_file):
738 cell_result = Result.TIMEOUT
739 elif not opts.timeout_bin and actual['status'] == 124:
740 cell_result = Result.TIMEOUT
741 else:
742 messages = []
743 cell_result = Result.PASS
744
745 # TODO: Warn about no assertions? Well it will always test the error
746 # code.
747 assertions = CreateAssertions(case, sh_label)
748 for a in assertions:
749 result, msg = a.Check(sh_label, actual)
750 # The minimum one wins.
751 # If any failed, then the result is FAIL.
752 # If any are OK, but none are FAIL, the result is OK.
753 cell_result = min(cell_result, result)
754 if msg:
755 messages.append(msg)
756
757 if cell_result != Result.PASS or opts.details:
758 d = (i, sh_label, actual['stdout'], actual['stderr'], messages)
759 out.AddDetails(d)
760
761 result_row.append(cell_result)
762
763 stats.ReportCell(i, cell_result, sh_label)
764
765 if sh_label in OTHER_OSH:
766 # This is only an error if we tried to run ANY OSH.
767 if osh_cpython_index == -1:
768 raise RuntimeError("Couldn't determine index of osh-cpython")
769
770 other_result = result_row[shell_index]
771 cpython_result = result_row[osh_cpython_index]
772 if other_result != cpython_result:
773 stats.Inc('oils_ALT_delta')
774
775 out.WriteRow(i, line_num, result_row, desc)
776
777 return stats
778
779
780# ANSI color constants
781_RESET = '\033[0;0m'
782_BOLD = '\033[1m'
783
784_RED = '\033[31m'
785_GREEN = '\033[32m'
786_YELLOW = '\033[33m'
787_PURPLE = '\033[35m'
788
789
790TEXT_CELLS = {
791 Result.TIMEOUT: 'TIME',
792 Result.FAIL: 'FAIL',
793 Result.BUG: 'BUG',
794 Result.NI: 'N-I',
795 Result.OK: 'ok',
796 Result.PASS: 'pass',
797}
798
799ANSI_COLORS = {
800 Result.TIMEOUT: _PURPLE,
801 Result.FAIL: _RED,
802 Result.BUG: _YELLOW,
803 Result.NI: _YELLOW,
804 Result.OK: _YELLOW,
805 Result.PASS: _GREEN,
806}
807
808def _AnsiCells():
809 lookup = {}
810 for i in xrange(Result.length):
811 lookup[i] = ''.join([ANSI_COLORS[i], _BOLD, TEXT_CELLS[i], _RESET])
812 return lookup
813
814ANSI_CELLS = _AnsiCells()
815
816
817HTML_CELLS = {
818 Result.TIMEOUT: '<td class="timeout">TIME',
819 Result.FAIL: '<td class="fail">FAIL',
820 Result.BUG: '<td class="bug">BUG',
821 Result.NI: '<td class="n-i">N-I',
822 Result.OK: '<td class="ok">ok',
823 Result.PASS: '<td class="pass">pass',
824}
825
826
827def _ValidUtf8String(s):
828 """Return an arbitrary string as a readable utf-8 string.
829
830 We output utf-8 to either HTML or the console. If we get invalid utf-8 as
831 stdout/stderr (which is very possible), then show the ASCII repr().
832 """
833 try:
834 s.decode('utf-8')
835 return s # it decoded OK
836 except UnicodeDecodeError:
837 return repr(s) # ASCII representation
838
839
840class Output(object):
841
842 def __init__(self, f, verbose):
843 self.f = f
844 self.verbose = verbose
845 self.details = []
846
847 def BeginCases(self, test_file):
848 pass
849
850 def WriteHeader(self, sh_labels):
851 pass
852
853 def WriteRow(self, i, line_num, row, desc):
854 pass
855
856 def EndCases(self, sh_labels, stats):
857 pass
858
859 def AddDetails(self, entry):
860 self.details.append(entry)
861
862 # Helper function
863 def _WriteDetailsAsText(self, details):
864 for case_index, shell, stdout, stderr, messages in details:
865 print('case: %d' % case_index, file=self.f)
866 for m in messages:
867 print(m, file=self.f)
868
869 # Assume the terminal can show utf-8, but we don't want random binary.
870 print('%s stdout:' % shell, file=self.f)
871 print(_ValidUtf8String(stdout), file=self.f)
872
873 print('%s stderr:' % shell, file=self.f)
874 print(_ValidUtf8String(stderr), file=self.f)
875
876 print('', file=self.f)
877
878
879class TeeOutput(object):
880 """For multiple outputs in one run, e.g. HTML and TSV.
881
882 UNUSED
883 """
884
885 def __init__(self, outs):
886 self.outs = outs
887
888 def BeginCases(self, test_file):
889 for out in self.outs:
890 out.BeginCases(test_file)
891
892 def WriteHeader(self, sh_labels):
893 for out in self.outs:
894 out.WriteHeader(sh_labels)
895
896 def WriteRow(self, i, line_num, row, desc):
897 for out in self.outs:
898 out.WriteRow(i, line_num, row, desc)
899
900 def EndCases(self, sh_labels, stats):
901 for out in self.outs:
902 out.EndCases(sh_labels, stats)
903
904 def AddDetails(self, entry):
905 for out in self.outs:
906 out.AddDetails(entry)
907
908
909class TsvOutput(Output):
910 """Write a plain-text TSV file.
911
912 UNUSED since we are outputting LONG format with --tsv-output.
913 """
914
915 def WriteHeader(self, sh_labels):
916 self.f.write('case\tline\t') # case number and line number
917 for sh_label in sh_labels:
918 self.f.write(sh_label)
919 self.f.write('\t')
920 self.f.write('\n')
921
922 def WriteRow(self, i, line_num, row, desc):
923 self.f.write('%3d\t%3d\t' % (i, line_num))
924
925 for result in row:
926 c = TEXT_CELLS[result]
927 self.f.write(c)
928 self.f.write('\t')
929
930 # note: 'desc' could use TSV8, but just ignore it for now
931 #self.f.write(desc)
932 self.f.write('\n')
933
934
935class AnsiOutput(Output):
936
937 def BeginCases(self, test_file):
938 self.f.write('%s\n' % test_file)
939
940 def WriteHeader(self, sh_labels):
941 self.f.write(_BOLD)
942 self.f.write('case\tline\t') # case number and line number
943 for sh_label in sh_labels:
944 self.f.write(sh_label)
945 self.f.write('\t')
946 self.f.write(_RESET)
947 self.f.write('\n')
948
949 def WriteRow(self, i, line_num, row, desc):
950 self.f.write('%3d\t%3d\t' % (i, line_num))
951
952 for result in row:
953 c = ANSI_CELLS[result]
954 self.f.write(c)
955 self.f.write('\t')
956
957 self.f.write(desc)
958 self.f.write('\n')
959
960 if self.verbose:
961 self._WriteDetailsAsText(self.details)
962 self.details = []
963
964 def _WriteShellSummary(self, sh_labels, stats):
965 if len(stats.nonzero_results) <= 1: # Skip trivial summaries
966 return
967
968 # Reiterate header
969 self.f.write(_BOLD)
970 self.f.write('\t\t')
971 for sh_label in sh_labels:
972 self.f.write(sh_label)
973 self.f.write('\t')
974 self.f.write(_RESET)
975 self.f.write('\n')
976
977 # Write totals by cell.
978 for result in sorted(stats.nonzero_results, reverse=True):
979 self.f.write('\t%s' % ANSI_CELLS[result])
980 for sh_label in sh_labels:
981 self.f.write('\t%d' % stats.by_shell[sh_label][result])
982 self.f.write('\n')
983
984 # The bottom row is all the same, but it helps readability.
985 self.f.write('\ttotal')
986 for sh_label in sh_labels:
987 self.f.write('\t%d' % stats.counters['num_cases_run'])
988 self.f.write('\n')
989
990 def EndCases(self, sh_labels, stats):
991 print()
992 self._WriteShellSummary(sh_labels, stats)
993
994
995class HtmlOutput(Output):
996
997 def __init__(self, f, verbose, spec_name, sh_labels, cases):
998 Output.__init__(self, f, verbose)
999 self.spec_name = spec_name
1000 self.sh_labels = sh_labels # saved from header
1001 self.cases = cases # for linking to code
1002 self.row_html = [] # buffered
1003
1004 def _SourceLink(self, line_num, desc):
1005 return '<a href="%s.test.html#L%d">%s</a>' % (
1006 self.spec_name, line_num, cgi.escape(desc))
1007
1008 def BeginCases(self, test_file):
1009 css_urls = [ '../../../web/base.css', '../../../web/spec-tests.css' ]
1010 title = '%s: spec test case results' % self.spec_name
1011 html_head.Write(self.f, title, css_urls=css_urls)
1012
1013 self.f.write('''\
1014 <body class="width60">
1015 <p id="home-link">
1016 <a href=".">spec test index</a>
1017 /
1018 <a href="/">oilshell.org</a>
1019 </p>
1020 <h1>Results for %s</h1>
1021 <table>
1022 ''' % test_file)
1023
1024 def _WriteShellSummary(self, sh_labels, stats):
1025 # NOTE: This table has multiple <thead>, which seems OK.
1026 self.f.write('''
1027<thead>
1028 <tr class="table-header">
1029 ''')
1030
1031 columns = ['status'] + sh_labels + ['']
1032 for c in columns:
1033 self.f.write('<td>%s</td>' % c)
1034
1035 self.f.write('''
1036 </tr>
1037</thead>
1038''')
1039
1040 # Write totals by cell.
1041 for result in sorted(stats.nonzero_results, reverse=True):
1042 self.f.write('<tr>')
1043
1044 self.f.write(HTML_CELLS[result])
1045 self.f.write('</td> ')
1046
1047 for sh_label in sh_labels:
1048 self.f.write('<td>%d</td>' % stats.by_shell[sh_label][result])
1049
1050 self.f.write('<td></td>')
1051 self.f.write('</tr>\n')
1052
1053 # The bottom row is all the same, but it helps readability.
1054 self.f.write('<tr>')
1055 self.f.write('<td>total</td>')
1056 for sh_label in sh_labels:
1057 self.f.write('<td>%d</td>' % stats.counters['num_cases_run'])
1058 self.f.write('<td></td>')
1059 self.f.write('</tr>\n')
1060
1061 # Blank row for space.
1062 self.f.write('<tr>')
1063 for i in xrange(len(sh_labels) + 2):
1064 self.f.write('<td style="height: 2em"></td>')
1065 self.f.write('</tr>\n')
1066
1067 def WriteHeader(self, sh_labels):
1068 f = cStringIO.StringIO()
1069
1070 f.write('''
1071<thead>
1072 <tr class="table-header">
1073 ''')
1074
1075 columns = ['case'] + sh_labels
1076 for c in columns:
1077 f.write('<td>%s</td>' % c)
1078 f.write('<td class="case-desc">description</td>')
1079
1080 f.write('''
1081 </tr>
1082</thead>
1083''')
1084
1085 self.row_html.append(f.getvalue())
1086
1087 def WriteRow(self, i, line_num, row, desc):
1088 f = cStringIO.StringIO()
1089 f.write('<tr>')
1090 f.write('<td>%3d</td>' % i)
1091
1092 show_details = False
1093
1094 for result in row:
1095 c = HTML_CELLS[result]
1096 if result not in (Result.PASS, Result.TIMEOUT): # nothing to show
1097 show_details = True
1098
1099 f.write(c)
1100 f.write('</td>')
1101 f.write('\t')
1102
1103 f.write('<td class="case-desc">')
1104 f.write(self._SourceLink(line_num, desc))
1105 f.write('</td>')
1106 f.write('</tr>\n')
1107
1108 # Show row with details link.
1109 if show_details:
1110 f.write('<tr>')
1111 f.write('<td class="details-row"></td>') # for the number
1112
1113 for col_index, result in enumerate(row):
1114 f.write('<td class="details-row">')
1115 if result != Result.PASS:
1116 sh_label = self.sh_labels[col_index]
1117 f.write('<a href="#details-%s-%s">details</a>' % (i, sh_label))
1118 f.write('</td>')
1119
1120 f.write('<td class="details-row"></td>') # for the description
1121 f.write('</tr>\n')
1122
1123 self.row_html.append(f.getvalue()) # buffer it
1124
1125 def _WriteStats(self, stats):
1126 self.f.write(
1127 '%(num_passed)d passed, %(num_ok)d OK, '
1128 '%(num_ni)d not implemented, %(num_bug)d BUG, '
1129 '%(num_failed)d failed, %(num_timeout)d timeouts, '
1130 '%(num_skipped)d cases skipped\n' % stats.counters)
1131
1132 def EndCases(self, sh_labels, stats):
1133 self._WriteShellSummary(sh_labels, stats)
1134
1135 # Write all the buffered rows
1136 for h in self.row_html:
1137 self.f.write(h)
1138
1139 self.f.write('</table>\n')
1140 self.f.write('<pre>')
1141 self._WriteStats(stats)
1142 if stats.Get('oils_num_failed'):
1143 self.f.write('%(oils_num_failed)d failed under osh\n' % stats.counters)
1144 self.f.write('</pre>')
1145
1146 if self.details:
1147 self._WriteDetails()
1148
1149 self.f.write('</body></html>')
1150
1151 def _WriteDetails(self):
1152 self.f.write("<h2>Details on runs that didn't PASS</h2>")
1153 self.f.write('<table id="details">')
1154
1155 for case_index, sh_label, stdout, stderr, messages in self.details:
1156 self.f.write('<tr>')
1157 self.f.write('<td><a name="details-%s-%s"></a><b>%s</b></td>' % (
1158 case_index, sh_label, sh_label))
1159
1160 self.f.write('<td>')
1161
1162 # Write description and link to the code
1163 case = self.cases[case_index]
1164 line_num = case['line_num']
1165 desc = case['desc']
1166 self.f.write('%d ' % case_index)
1167 self.f.write(self._SourceLink(line_num, desc))
1168 self.f.write('<br/><br/>\n')
1169
1170 for m in messages:
1171 self.f.write('<span class="assertion">%s</span><br/>\n' % cgi.escape(m))
1172 if messages:
1173 self.f.write('<br/>\n')
1174
1175 def _WriteRaw(s):
1176 self.f.write('<pre>')
1177
1178 # stdout might contain invalid utf-8; make it valid;
1179 valid_utf8 = _ValidUtf8String(s)
1180
1181 self.f.write(cgi.escape(valid_utf8))
1182 self.f.write('</pre>')
1183
1184 self.f.write('<i>stdout:</i> <br/>\n')
1185 _WriteRaw(stdout)
1186
1187 self.f.write('<i>stderr:</i> <br/>\n')
1188 _WriteRaw(stderr)
1189
1190 self.f.write('</td>')
1191 self.f.write('</tr>')
1192
1193 self.f.write('</table>')
1194
1195
1196def MakeTestEnv(opts):
1197 if not opts.tmp_env:
1198 raise RuntimeError('--tmp-env required')
1199 if not opts.path_env:
1200 raise RuntimeError('--path-env required')
1201 env = {
1202 'PATH': opts.path_env,
1203 #'LANG': opts.lang_env,
1204 }
1205 for p in opts.env_pair:
1206 name, value = p.split('=', 1)
1207 env[name] = value
1208
1209 return env
1210
1211
1212def _DefaultSuite(spec_name):
1213 if spec_name.startswith('ysh-'):
1214 suite = 'ysh'
1215 elif spec_name.startswith('hay'): # hay.test.sh is ysh
1216 suite = 'ysh'
1217
1218 elif spec_name.startswith('tea-'):
1219 suite = 'tea'
1220 else:
1221 suite = 'osh'
1222
1223 return suite
1224
1225
1226def ParseTestList(test_files):
1227 for test_file in test_files:
1228 with open(test_file) as f:
1229 tokens = Tokenizer(f)
1230 try:
1231 file_metadata, cases = ParseTestFile(test_file, tokens)
1232 except RuntimeError as e:
1233 log('ERROR in %r', test_file)
1234 raise
1235
1236 tmp = os.path.basename(test_file)
1237 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1238
1239 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1240
1241 tmp = file_metadata.get('tags')
1242 tags = tmp.split() if tmp else []
1243
1244 # Don't need compare_shells, etc. to decide what to run
1245
1246 row = {'spec_name': spec_name, 'suite': suite, 'tags': tags}
1247 #print(row)
1248 yield row
1249
1250
1251def main(argv):
1252 # First check if bash is polluting the environment. Tests rely on the
1253 # environment.
1254 v = os.getenv('RANDOM')
1255 if v is not None:
1256 raise AssertionError('got $RANDOM = %s' % v)
1257 v = os.getenv('PPID')
1258 if v is not None:
1259 raise AssertionError('got $PPID = %s' % v)
1260
1261 p = optparse.OptionParser('%s [options] TEST_FILE shell...' % sys.argv[0])
1262 spec_lib.DefineCommon(p)
1263 spec_lib.DefineShSpec(p)
1264 opts, argv = p.parse_args(argv)
1265
1266 # --print-tagged to figure out what to run
1267 if opts.print_tagged:
1268 to_find = opts.print_tagged
1269 for row in ParseTestList(argv[1:]):
1270 if to_find in row['tags']:
1271 print(row['spec_name'])
1272 return 0
1273
1274 # --print-table to figure out what to run
1275 if opts.print_table:
1276 for row in ParseTestList(argv[1:]):
1277 print('%(suite)s\t%(spec_name)s' % row)
1278 #print(row)
1279 return 0
1280
1281 #
1282 # Now deal with a single file
1283 #
1284
1285 try:
1286 test_file = argv[1]
1287 except IndexError:
1288 p.print_usage()
1289 return 1
1290
1291 with open(test_file) as f:
1292 tokens = Tokenizer(f)
1293 file_metadata, cases = ParseTestFile(test_file, tokens)
1294
1295 # List test cases and return
1296 if opts.do_list:
1297 for i, case in enumerate(cases):
1298 if opts.verbose: # print the raw dictionary for debugging
1299 print(pprint.pformat(case))
1300 else:
1301 print('%d\t%s' % (i, case['desc']))
1302 return 0
1303
1304 # for test/spec-cpp.sh
1305 if opts.print_spec_suite:
1306 tmp = os.path.basename(test_file)
1307 spec_name = tmp.split('.')[0] # foo.test.sh -> foo
1308
1309 suite = file_metadata.get('suite') or _DefaultSuite(spec_name)
1310 print(suite)
1311 return 0
1312
1313 if opts.verbose:
1314 for k, v in file_metadata.items():
1315 print('\t%-20s: %s' % (k, v), file=sys.stderr)
1316 print('', file=sys.stderr)
1317
1318 if opts.oils_bin_dir:
1319
1320 shells = []
1321
1322 if opts.compare_shells:
1323 comp = file_metadata.get('compare_shells')
1324 # Compare 'compare_shells' and Python
1325 shells.extend(comp.split() if comp else [])
1326
1327 # Always run with the Python version
1328 our_shell = file_metadata.get('our_shell', 'osh') # default is OSH
1329 shells.append(os.path.join(opts.oils_bin_dir, our_shell))
1330
1331 # Legacy OVM/CPython build
1332 if opts.ovm_bin_dir:
1333 shells.append(os.path.join(opts.ovm_bin_dir, our_shell))
1334
1335 # New C++ build
1336 if opts.oils_cpp_bin_dir:
1337 shells.append(os.path.join(opts.oils_cpp_bin_dir, our_shell))
1338
1339 # Overwrite it when --oils-bin-dir is set
1340 # It's no longer a flag
1341 opts.oils_failures_allowed = \
1342 int(file_metadata.get('oils_failures_allowed', 0))
1343
1344 else:
1345 # TODO: remove this mode?
1346 shells = argv[2:]
1347
1348 shell_pairs = spec_lib.MakeShellPairs(shells)
1349
1350 if opts.range:
1351 begin, end = spec_lib.ParseRange(opts.range)
1352 case_predicate = spec_lib.RangePredicate(begin, end)
1353 elif opts.regex:
1354 desc_re = re.compile(opts.regex, re.IGNORECASE)
1355 case_predicate = spec_lib.RegexPredicate(desc_re)
1356 else:
1357 case_predicate = lambda i, case: True
1358
1359 out_f = sys.stderr if opts.do_print else sys.stdout
1360
1361 # Set up output style. Also see asdl/format.py
1362 if opts.format == 'ansi':
1363 out = AnsiOutput(out_f, opts.verbose)
1364
1365 elif opts.format == 'html':
1366 spec_name = os.path.basename(test_file)
1367 spec_name = spec_name.split('.')[0]
1368
1369 sh_labels = [label for label, _ in shell_pairs]
1370
1371 out = HtmlOutput(out_f, opts.verbose, spec_name, sh_labels, cases)
1372
1373 else:
1374 raise AssertionError()
1375
1376 out.BeginCases(os.path.basename(test_file))
1377
1378 env = MakeTestEnv(opts)
1379 stats = RunCases(cases, case_predicate, shell_pairs, env, out, opts)
1380
1381 out.EndCases([sh_label for sh_label, _ in shell_pairs], stats)
1382
1383 if opts.tsv_output:
1384 with open(opts.tsv_output, 'w') as f:
1385 stats.WriteTsv(f)
1386
1387 # TODO: Could --stats-{file,template} be a separate awk step on .tsv files?
1388 stats.Set('oils_failures_allowed', opts.oils_failures_allowed)
1389 if opts.stats_file:
1390 with open(opts.stats_file, 'w') as f:
1391 f.write(opts.stats_template % stats.counters)
1392 f.write('\n') # bash 'read' requires a newline
1393
1394 # spec/smoke.test.sh -> smoke
1395 test_name = os.path.basename(test_file).split('.')[0]
1396
1397 return _SuccessOrFailure(test_name, opts.oils_failures_allowed, stats)
1398
1399
1400def _SuccessOrFailure(test_name, allowed, stats):
1401 all_count = stats.Get('num_failed')
1402 oils_count = stats.Get('oils_num_failed')
1403
1404 # If we got EXACTLY the allowed number of failures, exit 0.
1405 if allowed == all_count and all_count == oils_count:
1406 log('%s: note: Got %d allowed oils failures (exit with code 0)',
1407 test_name, allowed)
1408 return 0
1409 else:
1410 log('')
1411 log('%s: FATAL: Got %d failures (%d oils failures), but %d are allowed',
1412 test_name, all_count, oils_count, allowed)
1413 log('')
1414
1415 return 1
1416
1417
1418if __name__ == '__main__':
1419 try:
1420 sys.exit(main(sys.argv))
1421 except KeyboardInterrupt as e:
1422 print('%s: interrupted with Ctrl-C' % sys.argv[0], file=sys.stderr)
1423 sys.exit(1)
1424 except RuntimeError as e:
1425 print('FATAL: %s' % e, file=sys.stderr)
1426 sys.exit(1)
1427
1428# vim: sw=2